snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
1
apps/dotcom/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.snapshots
|
||||
34
apps/dotcom/README.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# dotcom
|
||||
|
||||
## Development
|
||||
|
||||
You'll need a clerk publishable and secret key.
|
||||
|
||||
In `sync-worker/.dev.vars`, set `CLERK_PUBLISHABLE_KEY` & `CLERK_SECRET_KEY`. In `client/.env.local`, set `VITE_CLERK_PUBLISHABLE_KEY`.
|
||||
|
||||
The dev stack is orchestrated by [process-compose](https://github.com/F1bonacc1/process-compose). You don't need to install it — `yarn dev-app` fetches the pinned binary on first use. Run the app from the repo root:
|
||||
|
||||
```bash
|
||||
yarn dev-app
|
||||
```
|
||||
|
||||
This brings up the whole stack — postgres and pgbouncer (in containers), zero-cache, the workers, and the client — as host processes. The ports are fixed, so only one dotcom dev stack can run at a time. The services and their startup order are defined in [`process-compose.yaml`](process-compose.yaml).
|
||||
|
||||
### Working with the stack
|
||||
|
||||
By default `yarn dev-app` opens the process-compose TUI, listing each service with its status, logs, and health.
|
||||
|
||||
- **Quit:** `F10` or `Ctrl-C`. This stops every process and runs postgres's `docker compose down` — the clean way to stop. Closing the terminal tab instead can leave the postgres container and stray workers running.
|
||||
- **Inspect a service:** select it with the arrow keys to see its logs; press `F1` for the full key bindings (start / stop / restart a selected process).
|
||||
- **Plain interleaved logs, no TUI:** `PC_DISABLE_TUI=1 yarn dev-app` streams every service's logs to stdout (this is also how it runs in CI).
|
||||
- **Drive it from another terminal or a script** — the `process-compose` client connects to the running stack:
|
||||
|
||||
```bash
|
||||
yarn dev-app:doctor # status of every service
|
||||
yarn exec process-compose process logs zero-cache --tail 200
|
||||
yarn exec process-compose process restart sync-worker
|
||||
```
|
||||
|
||||
- **Reset server state:** `yarn dev-app:clean` tears down the postgres container + volume, the zero replica, and wrangler state.
|
||||
|
||||
Browser-side state is separate. After starting the stack, visit `http://localhost:3000/dev/reset-local-state` to clear local storage, IndexedDB, caches, service workers, accessible cookies, and Clerk session state for the current origin.
|
||||
1
apps/dotcom/asset-upload-worker/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
tmp-assets
|
||||
27
apps/dotcom/asset-upload-worker/README.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# dotcom-asset-upload
|
||||
|
||||
Asset uploading for tldraw.com.
|
||||
|
||||
## License
|
||||
|
||||
The code in this folder is Copyright (c) 2024-present tldraw Inc. The tldraw SDK is provided under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md).
|
||||
|
||||
## Trademarks
|
||||
|
||||
Copyright (c) 2024-present tldraw Inc. The tldraw name and logo are trademarks of tldraw. Please see our [trademark guidelines](https://github.com/tldraw/tldraw/blob/main/TRADEMARKS.md) for info on acceptable usage.
|
||||
|
||||
## Distributions
|
||||
|
||||
You can find tldraw on npm [here](https://www.npmjs.com/package/@tldraw/tldraw?activeTab=versions).
|
||||
|
||||
## Contribution
|
||||
|
||||
Found a bug? Please [submit an issue](https://github.com/tldraw/tldraw/issues/new).
|
||||
|
||||
## Community
|
||||
|
||||
Have questions, comments or feedback? [Join our discord](https://discord.tldraw.com/?utm_source=github&utm_medium=readme&utm_campaign=sociallink). For the latest news and release notes, visit [tldraw.dev](https://tldraw.dev).
|
||||
|
||||
## Contact
|
||||
|
||||
Find us on Twitter/X at [@tldraw](https://twitter.com/tldraw).
|
||||
28
apps/dotcom/asset-upload-worker/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "dotcom-asset-upload",
|
||||
"description": "A Cloudflare Worker to upload and serve images",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"author": {
|
||||
"name": "tldraw GB Ltd.",
|
||||
"email": "hello@tldraw.com"
|
||||
},
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"dev": "yarn run -T tsx ../../../internal/scripts/workers/dev.ts --persist-to tmp-assets --inspector-port 9449",
|
||||
"test-ci": "yarn run -T vitest run --passWithNoTests",
|
||||
"test": "yarn run -T vitest --passWithNoTests",
|
||||
"test-coverage": "yarn run -T vitest run --coverage --passWithNoTests",
|
||||
"lint": "yarn run -T tsx ../../../internal/scripts/lint.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tldraw/worker-shared": "workspace:*",
|
||||
"itty-router": "^5.0.18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20260302.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"lazyrepo": "0.0.0-alpha.27",
|
||||
"wrangler": "^4.75.0"
|
||||
}
|
||||
}
|
||||
13
apps/dotcom/asset-upload-worker/src/types.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { R2Bucket, WorkerVersionMetadata } from '@cloudflare/workers-types'
|
||||
|
||||
export interface Environment {
|
||||
// bindings
|
||||
UPLOADS: R2Bucket
|
||||
CF_VERSION_METADATA: WorkerVersionMetadata
|
||||
|
||||
// environment variables
|
||||
TLDRAW_ENV: string | undefined
|
||||
SENTRY_DSN: string | undefined
|
||||
IS_LOCAL: string | undefined
|
||||
WORKER_NAME: string | undefined
|
||||
}
|
||||
50
apps/dotcom/asset-upload-worker/src/worker.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/// <reference no-default-lib="true"/>
|
||||
/// <reference types="@cloudflare/workers-types" />
|
||||
|
||||
import {
|
||||
blockUnknownOrigins,
|
||||
createRouter,
|
||||
handleApiRequest,
|
||||
handleUserAssetGet,
|
||||
handleUserAssetUpload,
|
||||
isAllowedOrigin,
|
||||
notFound,
|
||||
} from '@tldraw/worker-shared'
|
||||
import { WorkerEntrypoint } from 'cloudflare:workers'
|
||||
import { cors } from 'itty-router'
|
||||
import { Environment } from './types'
|
||||
|
||||
const { preflight, corsify } = cors({ origin: isAllowedOrigin })
|
||||
|
||||
export default class Worker extends WorkerEntrypoint<Environment> {
|
||||
readonly router = createRouter<Environment>()
|
||||
.all('*', preflight)
|
||||
.all('*', blockUnknownOrigins)
|
||||
.get('/uploads/:objectName', async (request) => {
|
||||
return handleUserAssetGet({
|
||||
request,
|
||||
bucket: this.env.UPLOADS,
|
||||
objectName: request.params.objectName,
|
||||
context: this.ctx,
|
||||
})
|
||||
})
|
||||
.post('/uploads/:objectName', async (request) => {
|
||||
return handleUserAssetUpload({
|
||||
headers: request.headers,
|
||||
body: request.body,
|
||||
bucket: this.env.UPLOADS,
|
||||
objectName: request.params.objectName,
|
||||
})
|
||||
})
|
||||
.all('*', notFound)
|
||||
|
||||
override async fetch(request: Request) {
|
||||
return handleApiRequest({
|
||||
router: this.router,
|
||||
request,
|
||||
env: this.env,
|
||||
ctx: this.ctx,
|
||||
after: corsify,
|
||||
})
|
||||
}
|
||||
}
|
||||
7
apps/dotcom/asset-upload-worker/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../../internal/config/tsconfig.base.json",
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist", ".tsbuild*"],
|
||||
"compilerOptions": { "noEmit": true, "emitDeclarationOnly": false },
|
||||
"references": [{ "path": "../../../packages/worker-shared" }]
|
||||
}
|
||||
10
apps/dotcom/asset-upload-worker/vitest.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/// <reference types="vitest" />
|
||||
import { mergeConfig } from 'vitest/config'
|
||||
import baseConfig from '../../../internal/config/vitest/node-preset'
|
||||
|
||||
export default mergeConfig(baseConfig, {
|
||||
test: {
|
||||
environment: 'node',
|
||||
passWithNoTests: true,
|
||||
},
|
||||
})
|
||||
81
apps/dotcom/asset-upload-worker/wrangler.toml
Normal file
@@ -0,0 +1,81 @@
|
||||
main = "src/worker.ts"
|
||||
compatibility_date = "2024-06-20"
|
||||
|
||||
[dev]
|
||||
port = 8788
|
||||
|
||||
[[analytics_engine_datasets]]
|
||||
binding = "MEASURE"
|
||||
|
||||
#################### Environment names ####################
|
||||
# dev should never actually get deployed anywhere
|
||||
[env.dev]
|
||||
name = 'tldraw-assets-dev'
|
||||
|
||||
# we don't have a hard-coded name for preview. we instead have to generate it at build time and append it to this file.
|
||||
|
||||
# staging is the same as a preview on main:
|
||||
[env.staging]
|
||||
name = "main-tldraw-assets"
|
||||
|
||||
# production gets the proper name
|
||||
[env.production]
|
||||
name = "tldraw-assets"
|
||||
|
||||
#################### Assets R2 bucket ####################
|
||||
# in dev, we write to the preview bucket and need a `preview_bucket_name`
|
||||
[[env.dev.r2_buckets]]
|
||||
binding = 'UPLOADS'
|
||||
bucket_name = 'uploads-preview'
|
||||
preview_bucket_name = 'uploads-preview'
|
||||
|
||||
# in preview and staging we write to the preview bucket
|
||||
[[env.preview.r2_buckets]]
|
||||
binding = 'UPLOADS'
|
||||
bucket_name = 'uploads-preview'
|
||||
|
||||
[[env.staging.r2_buckets]]
|
||||
binding = 'UPLOADS'
|
||||
bucket_name = 'uploads-preview'
|
||||
|
||||
# in production, we write to the main bucket
|
||||
[[env.production.r2_buckets]]
|
||||
binding = "UPLOADS"
|
||||
bucket_name = "uploads"
|
||||
|
||||
#################### Analytics engine ####################
|
||||
# analytics engine has the same configuration in all environments:
|
||||
[[env.dev.analytics_engine_datasets]]
|
||||
binding = "MEASURE"
|
||||
|
||||
[[env.preview.analytics_engine_datasets]]
|
||||
binding = "MEASURE"
|
||||
|
||||
[[env.staging.analytics_engine_datasets]]
|
||||
binding = "MEASURE"
|
||||
|
||||
[[env.production.analytics_engine_datasets]]
|
||||
binding = "MEASURE"
|
||||
|
||||
#################### Routes ####################
|
||||
# in production, we use a custom domain. others get a default *.tldraw.workers.dev domain
|
||||
[[env.production.routes]]
|
||||
pattern = 'assets.tldraw.xyz'
|
||||
custom_domain = true
|
||||
zone_name = 'tldraw.xyz'
|
||||
|
||||
#################### Version metadata ####################
|
||||
[version_metadata]
|
||||
binding = "CF_VERSION_METADATA"
|
||||
|
||||
[env.dev.version_metadata]
|
||||
binding = "CF_VERSION_METADATA"
|
||||
|
||||
[env.preview.version_metadata]
|
||||
binding = "CF_VERSION_METADATA"
|
||||
|
||||
[env.staging.version_metadata]
|
||||
binding = "CF_VERSION_METADATA"
|
||||
|
||||
[env.production.version_metadata]
|
||||
binding = "CF_VERSION_METADATA"
|
||||
42
apps/dotcom/client/.gitignore
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# PWA build artifacts
|
||||
/dev-dist
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
/tmp
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Sentry
|
||||
.sentryclirc
|
||||
118
apps/dotcom/client/PRIVACY_POLICY.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# Privacy Policy (tldraw.com)
|
||||
|
||||
**Effective Date:** 29 July 2024
|
||||
|
||||
## 1. Introduction
|
||||
|
||||
This Privacy Policy (the “Policy”) explains how tldraw GB Limited (“tldraw”, “we”, “us”, “our”) collects, uses, and discloses personal information through its Website and the whiteboard functionality on the Website (the “Whiteboard”, and collectively with the Website, the “Services”). By using or accessing the Services in any manner, you acknowledge that you accept the practices and policies outlined in this Policy, and you hereby consent that we will collect, use, and share your personal information in the following ways. Any capitalized terms that are not defined in this Policy are defined in our Terms of Service.
|
||||
|
||||
## 2. Information collected and how we use it
|
||||
|
||||
As explained further in this section, you will have the opportunity to provide us with certain personal information. In addition, we may collect certain personal information automatically through your use of the Services. The rest of this section provides a more detailed explanation of the personal information we collect, how we use that personal information, and our lawful bases for processing that personal information.
|
||||
|
||||
### 2.1. Voluntarily disclosed information
|
||||
|
||||
The following table identifies the specific purposes for which you may voluntarily disclose personal information to us, along with our lawful bases for processing that personal information.
|
||||
|
||||
| Purpose for Collection | Type of Personal Information | How we collect that information | Lawful Basis for Processing |
|
||||
| ------------------------------------------------------ | ------------------------------------------------------------------------------------ | ------------------------------------- | ------------------------------------------------- |
|
||||
| For you to identify yourself when using the Whiteboard | Name (if you choose to identify yourself by your real name) | Directly from you | Your consent; Performance of a contract with you |
|
||||
| Your use of the Whiteboard | Whatever personal information you or another user choose to submit to the Whiteboard | Directly from you or from other users | Your consent; Performance of a contract with you; |
|
||||
|
||||
### 2.2. Automatically collected information
|
||||
|
||||
Whenever you interact with the Services, we automatically receive and record information on our server logs from your browser or device, which may include your IP address, geolocation data, device identification, the type of device you’re using to access the Services. We collect this information to perform our contract with you, and for our legitimate interest of providing a smooth user experience on the Services.
|
||||
|
||||
## 3. Disclosure of personal information
|
||||
|
||||
We may disclose your personal information as detailed in this section.
|
||||
|
||||
### 3.1. Personnel and third party service providers
|
||||
|
||||
We employ personnel and engage other companies and people to perform tasks on our behalf and need to share your personal information with them to provide the Services. For example, all data that we receive is stored on servers hosted by third party service providers.
|
||||
|
||||
### 3.2. Affiliate
|
||||
|
||||
We may share your personal information with our affiliates as necessary for the operation, performance, and improvement of the Services.
|
||||
|
||||
### 3.3. Other users
|
||||
|
||||
Please note that all “shared” Whiteboards (those with URLs beginning with tldraw.com/r, tldraw.com/s, tldraw.com/ro, or tldraw.com/v) are public. As part of the functionality of the Website, any user who visits the unique URL for the Whiteboard will be able to see the Whiteboard, including any personal information you may have added to it, export a copy of the Whiteboard, or edit its contents. Accordingly, only include information in the Whiteboard that you are comfortable sharing with other users.
|
||||
|
||||
### 3.4. Business transfers
|
||||
|
||||
If we (or our assets) are acquired, or if we go out of business, enter bankruptcy, or go through some other change of control, personal information could be one of the assets transferred to or acquired by a third party.
|
||||
|
||||
### 3.5. Legal compliance
|
||||
|
||||
We reserve the right to access, read, preserve, and disclose any information that we believe is necessary to comply with governmental requests, law enforcement or court orders, or enforce or apply our Terms of Service and other agreements.
|
||||
|
||||
## 4. International transfers
|
||||
|
||||
We may transfer the personal information of users from the European Economic Area or United Kingdom to the United States. When doing so, we rely on adequacy decisions, data transfer agreements, or other legally compliant mechanisms for such transfers, including standard contractual clauses. You can ask for a copy of these standard contractual clauses by contacting us as set out below.
|
||||
|
||||
## 5. Security
|
||||
|
||||
We use commercially reasonable physical, managerial, and technical safeguards to preserve the integrity and security of your personal information. In addition, we rely on the technical safeguards provided by the third party service providers we use to host, store, and process your personal information. We cannot, however, ensure or warrant that your personal information on the Services may not be accessed, disclosed, altered, or destroyed by breach of any of our physical, managerial, or technical safeguards.
|
||||
|
||||
## 6. Your rights
|
||||
|
||||
### 6.1. Rights of users in the EEA and UK
|
||||
|
||||
If you reside in the EEA or the UK and we are a controller of your personal information, you have the following rights:
|
||||
|
||||
#### 6.1.1. Request access to your personal information
|
||||
|
||||
You may request a copy of the personal information we hold about you and to check that we are lawfully processing it. Where we have good reason, and where applicable law permits, we can refuse your request for a copy of your personal information, or certain elements of the request. If we refuse your request or any element of it, we will provide you with our reason(s) for doing so.
|
||||
|
||||
#### 6.1.2. Request correction of your personal information
|
||||
|
||||
You may request that we correct any incomplete or inaccurate data we hold about you.
|
||||
|
||||
#### 6.1.3. Request erasure of your personal information
|
||||
|
||||
You may request that we delete or remove personal information where there is no good reason for us continuing to process it. You may also ask us to delete or remove your personal information where you have successfully exercised your right to object to processing (see below), where we may have processed your personal information unlawfully or where we are required to erase your personal information to comply with local law. Note, however, that we may not always be able to comply with your request of erasure for specific legal reasons which will be notified to you, if applicable, at the time of your request. You may not request the removal of de-identified, anonymous, or aggregate data from our databases.
|
||||
|
||||
#### 6.1.4. Right to object to or restrict processing
|
||||
|
||||
In certain circumstances, you have the right to object to our processing of your personal information (for example, if we are processing your personal information on the basis of our legitimate interests but there are no longer any compelling legitimate grounds to justify our processing overriding your rights and interests). You may also restrict our processing of your personal information, for example during a period in which we are verifying the accuracy of your personal information in circumstances where you have challenged the accuracy of that personal information.
|
||||
|
||||
#### 6.1.5. Request the transfer of your personal information to you or to a third party
|
||||
|
||||
In certain instances, you have a right to receive the personal information that we hold about you (or a portion thereof) in a structured, commonly used and machine-readable format. In such circumstances, you can ask us to transmit your personal information to you or directly to a third-party organization on your behalf. While we are happy for such requests to be made, we are not able to guarantee technical compatibility with a third-party organization’s systems. We are also unable to comply with requests that relate to personal information of others without their consent.
|
||||
|
||||
#### 6.1.6. Withdraw consent
|
||||
|
||||
You may withdraw your consent for our processing of your personal information where we are relying on consent to process that personal information. However, this will not affect the lawfulness of any processing carried out before you withdraw your consent. If you withdraw your consent, we may not be able to provide certain products or services to you. We will advise you if this is the case at the time you withdraw your consent.
|
||||
|
||||
### 6.2. Rights of all other users
|
||||
|
||||
Regardless of where you reside, if you would like us to delete your personal information, you can contact us as set forth in Section 6.3. Please understand, however, that it may be impossible to delete this information completely, due to backups and records of deletions. In addition, we may deny a deletion request if (i) the denial is necessary for us to comply with applicable law, or (ii) the request pertains to content that you posted on a Whiteboard and that cannot be deleted without impacting the Whiteboard experience for other users. You may not request the removal of de-identified, anonymous, or aggregate data from our databases.
|
||||
|
||||
### 6.3. How to exercise your rights
|
||||
|
||||
If you wish to exercise any of the rights set out above and we are the controller of your personal information, please contact us at hello@tldraw.com. Please note that to protect your personal information and the integrity of our Services, we may need to collect additional information to verify your identity before processing your request.
|
||||
|
||||
### 6.4. Supervisory authorities
|
||||
|
||||
We welcome and appreciate the chance to address any concerns you may have about the Policy and our collection and use of your personal information. To the extent you feel like we have not addressed your concerns, and depending on your jurisdiction, you may have the right to make a complaint at any time to your data protection supervisory authority. For end users in the EEA, you can find contact information for each country’s supervisory authority [here](https://www.edpb.europa.eu/about-edpb/about-edpb/members_en). For end users in the UK, you can find contact information for the Information Commissioner’s Office (ICO) on the ICO’s website [here](https://ico.org.uk/).
|
||||
|
||||
## 7. Retention of information
|
||||
|
||||
Subject to your right to request deletion of your personal information in accordance with Section 6, we will retain your personal information as long as needed for your use of the Services, our compliance with legal obligations, and to protect our or other’s interests.
|
||||
|
||||
## 8. How we respond to do not track signals
|
||||
|
||||
We do not track you or collect your personal information across third party websites or online services. Thus, we do not receive Do-Not-Track signals, or other similar signals. To the extent that we do receive any such signals, we will not comply with them as it is not an aspect of the functionality of the Services.
|
||||
|
||||
## 9. Age of users
|
||||
|
||||
Children under the age of 13 are not permitted to use, access or register for the Services in any way. We do not knowingly collect or solicit information from anyone under the age of 13. If we learn that we have collected personal information from a child under the age of 13, we will delete that information as quickly as possible.
|
||||
|
||||
## 10. Changes to policy
|
||||
|
||||
We’re constantly trying to improve the Services, so we may need to change this Policy from time to time as well. The date of the last modification will be posted at the beginning of this Policy. It is your responsibility to check from time to time for updates. By continuing to access or use the Services, you are indicating that you agree to be bound by the modified Policy.
|
||||
|
||||
## 11. Contact us
|
||||
|
||||
If you have any questions or concerns regarding this Policy, please send us a detailed message to our data protection officer at steve+data@tldraw.com, and we will try to resolve your concerns. In addition, please contact hello@tldraw.com for all other inquiries.
|
||||
29
apps/dotcom/client/README.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Project overview
|
||||
|
||||
This project contains the frontend for the **tldraw.com** application. It's built using Vite and React router.
|
||||
|
||||
See our:
|
||||
|
||||
- [License](https://github.com/tldraw/tldraw/blob/main/apps/dotcom/client/LICENSE.md)
|
||||
- [Terms of service](https://github.com/tldraw/tldraw/blob/main/apps/dotcom/client/TERMS_OF_SERVICE.md)
|
||||
- [Privacy policy](https://github.com/tldraw/tldraw/blob/main/apps/dotcom/client/PRIVACY_POLICY.md)
|
||||
|
||||
## Contribution
|
||||
|
||||
Found a bug? Please [submit an issue](https://github.com/tldraw/tldraw/issues/new).
|
||||
|
||||
## License
|
||||
|
||||
The code in this folder is Copyright (c) 2024-present tldraw Inc. The tldraw SDK is provided under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md).
|
||||
|
||||
## Trademarks
|
||||
|
||||
Copyright (c) 2024-present tldraw Inc. The tldraw name and logo are trademarks of tldraw. Please see our [trademark guidelines](https://github.com/tldraw/tldraw/blob/main/TRADEMARKS.md) for info on acceptable usage.
|
||||
|
||||
## Contact
|
||||
|
||||
Find us on Twitter/X at [@tldraw](https://twitter.com/tldraw).
|
||||
|
||||
## Community
|
||||
|
||||
Have questions, comments or feedback? [Join our discord](https://discord.tldraw.com/?utm_source=github&utm_medium=readme&utm_campaign=sociallink). For the latest news and release notes, visit [tldraw.dev](https://tldraw.dev).
|
||||
132
apps/dotcom/client/TERMS_OF_SERVICE.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Terms of Service (tldraw.com)
|
||||
|
||||
**Effective Date:** 29 July 2024
|
||||
|
||||
## 1. Agreement
|
||||
|
||||
The following Terms of Service (the **"Terms"**) constitute a binding agreement between you and tldraw GB Limited (**"tldraw," “we,” “our,” and “us”**). These Terms set forth conditions regarding your access to and use of [https://www.tldraw.com/](https://www.tldraw.com/) (the **“Website”**). By accessing or using the Website in any manner, you agree to be bound by these Terms.
|
||||
|
||||
PLEASE NOTE THAT, EXCEPT AS PROVIDED BELOW, THESE TERMS REQUIRE RESOLUTION OF DISPUTES THROUGH USE OF AN ARBITRATION SERVICE. YOU HEREBY AGREE THAT ALL DISPUTES ARISING FROM OR RELATED TO YOUR USE OF THE WEBSITE WILL BE RESOLVED IN ACCORDANCE WITH SECTION 17 BELOW.
|
||||
|
||||
## 2. Modification
|
||||
|
||||
tldraw reserves the right, at its sole discretion, to modify these Terms at any time and without prior notice. The date of the last modification to the Terms will be posted at the beginning of these Terms. By continuing to access or use the Website, you are indicating that you agree to be bound by any modified Terms.
|
||||
|
||||
## 3. Privacy
|
||||
|
||||
These Terms include the provisions in this document as well as those in our Privacy Policy.
|
||||
|
||||
## 4. Acceptable use
|
||||
|
||||
tldraw hereby grants you permission to access and use the Website, provided such use is in compliance with these Terms, and you further specifically agree that your use will adhere to the following restrictions and obligations:
|
||||
|
||||
- You may only use the Website in accordance with these Terms.
|
||||
- You may only use the Website for lawful activity. It is your responsibility to comply with all applicable local, state, and federal laws and regulations.
|
||||
- You may not make any unauthorized use of the Website.
|
||||
- You may not use the Website in any manner that tldraw deems to be harmful, violent, fraudulent, deceptive, threatening, harassing, defamatory, obscene, or otherwise objectionable.
|
||||
- You may not interfere with, disrupt, or create an undue burden on the Website or the networks or services connected to the Website.
|
||||
- You may not upload or transmit (or attempt to upload or to transmit) any material that acts as a passive or active information collection or transmission mechanism, including without limitation, clear graphics interchange formats (“gifs”), 1×1 pixels, web bugs, cookies, or other similar devices.
|
||||
- You may not decompile, reverse engineer, or otherwise attempt to obtain the source code or underlying ideas or information of or relating to the Website.
|
||||
- You may not copy, rip, or capture any content encountered on the Website. This includes bulk copying or "scraping" any portion of the Website content using a bot or other tool.
|
||||
- You may not enter, store or transmit viruses, worms or other malicious code within, through, to or using the Website.
|
||||
- You may not defeat, avoid, bypass, remove, deactivate or otherwise circumvent any software protection mechanisms in the Website.
|
||||
- You may not remove or obfuscate any product identification, copyright or other proprietary notice from any element of the Website or associated documentation.
|
||||
- You may not engage in unauthorized framing of or linking to the Website.
|
||||
|
||||
## 5. Content
|
||||
|
||||
As between you and tldraw, tldraw owns all right, title and interest in and to the materials made available to you through the Website, including, but not limited to, text, graphics, data, photos, images, videos, and illustrations (all of the foregoing except User Content, the **“tldraw Content”**).
|
||||
|
||||
You may not use, copy, adapt, modify, prepare derivative works based upon, distribute, license, sell, transfer, publicly display, transmit, broadcast, or otherwise exploit the tldraw Content, except as necessary to access and use the Website in accordance with these Terms.
|
||||
|
||||
You can use the whiteboard functionality on the Website to post, upload, submit and transmit content (collectively, **“User Content”**). As between you and tldraw, you own all right, title, and interest in and to your User Content. By submitting any User Content on or through the Website, you grant to tldraw a worldwide, irrevocable, perpetual, non-exclusive, transferable, royalty-free license, with the right to sublicense, to use, view, copy, adapt, modify, distribute, publicly display, transmit, access, and otherwise exploit such User Content, in order to provide the whiteboard functionality to you and any other users who you may share your whiteboard with. To the extent that your User Content includes personally identifiable information, we will only disclose such information in the limited circumstances identified in our Privacy Policy.
|
||||
|
||||
To the extent you share your whiteboard with other users, you hereby grant to those users a worldwide, irrevocable, perpetual, non-exclusive, transferable, royalty-free license, to use, view, copy, adapt, modify, distribute, publicly display, transmit, access, and otherwise exploit any of your User Content that is on that whiteboard.
|
||||
|
||||
You acknowledge and agree that you are solely responsible for your User Content. Accordingly, you represent and warrant that (i) you have all rights, licenses, consents and releases that are necessary to grant to tldraw and other users the licenses above, and (ii) each identifiable individual person in your User Content has given you written consent to use the person’s name or likeness (as applicable) in your User Content in any manner contemplated by the Website and these Terms.
|
||||
|
||||
TLDRAW HEREBY EXPRESSLY DISCLAIMS, AND YOU HEREBY EXPRESSLY RELEASE TLDRAW FROM, ANY AND ALL LIABILITY WHATSOEVER FOR ANY CONTROVERSIES, CLAIMS, SUITS, INJURIES, LOSS, HARM OR DAMAGES ARISING FROM OR RELATED TO YOUR USER CONTENT AND ANY DISPUTES, DEALINGS, OR INTERACTIONS BETWEEN YOU AND ANY OTHER USERS OR THIRD PARTIES.
|
||||
|
||||
## 6. Feedback
|
||||
|
||||
We welcome and encourage you to provide feedback, comments and suggestions for improvements to the Website (“Feedback”). You agree that tldraw has the right, but not the obligation, to use such Feedback without any obligation to provide you credit, royalty payment, or ownership interest in the changes to the Website.
|
||||
|
||||
## 7. Copyright Infringement
|
||||
|
||||
tldraw respects copyright law and expects our users to do the same. In accordance with the Digital Millennium Copyright Act (“DMCA”) of 1998, the text of which may be found on the U.S. Copyright Office website at [https://www.copyright.gov/legislation/dmca.pdf](https://www.copyright.gov/legislation/dmca.pdf), tldraw will respond to claims of copyright infringement committed using the Website that are reported to us, provided they meet the criteria below.
|
||||
|
||||
If you are a copyright owner, or a designated agent thereof, please report alleged copyright infringements taking place on or through the Website by providing us the following information (the “Notice”):
|
||||
|
||||
- the identity of the copyrighted work that you claim has been infringed, or, if multiple copyrighted works are covered by this Notice, a comprehensive list of the copyrighted works that you claim have been infringed;
|
||||
- the material that you claim is infringing, and information reasonably sufficient to permit us to locate the material, including at a minimum, the URL of the link shown on the Website where such material may be found;
|
||||
- your mailing address, telephone number, and, if available, email address;
|
||||
- a statement that you have a good faith belief that the disputed use of the copyrighted material is not authorized by the copyright owner, its agent, or the law;
|
||||
- a statement that the information in this Notice is accurate and, under penalty of perjury, that you are the owner, or authorized to act on behalf of the owner, of the copyright or of an exclusive right under the copyright that is allegedly infringed; and
|
||||
- your full legal name and your electronic or physical signature.
|
||||
|
||||
You may deliver this notice, with all items completed, to us, as follows:
|
||||
|
||||
**Email:** hello@tldraw.com
|
||||
|
||||
**Mail:**
|
||||
|
||||
tldraw
|
||||
5 New Street Square
|
||||
London EC4A 3TW
|
||||
United Kingdom
|
||||
|
||||
Upon receipt of the Notice as described above, tldraw will take whatever action, in its sole discretion, it deems appropriate, including removal of the challenged material from the Website.
|
||||
|
||||
## 8. Third-party websites
|
||||
|
||||
We may provide you with access to third party websites, information, and services, including but not limited to third party databases, networks, servers, software, programs, systems, directories, applications, or products. You hereby acknowledge that we do not control such third-party websites and services, and cannot be held responsible for their content, operation, or use. Your use of those services is subject to their respective terms of service. We do not give any representation, warranty, or endorsement, express or implied, with respect to the legality, accuracy, quality, or authenticity of content, information, or services provided by such third-party websites and services. We disclaim any and all responsibility or liability for any harm resulting from your use of such third-party websites and services, and you hereby irrevocably waive any claim against us with respect to the content or operation of any such third-party websites and services.
|
||||
|
||||
## 9. Termination
|
||||
|
||||
tldraw may immediately and without notice terminate these Terms and disable your access to the Website if tldraw determines, in its sole discretion, that (a) you have breached these Terms, or (b) you have violated applicable laws, regulations or third party rights.
|
||||
|
||||
Provisions that, by their nature, should survive termination of these Terms shall survive termination. By way of example, all of the following will survive termination: any limitations on our liability, any terms regarding ownership or intellectual property rights, and terms regarding indemnification and disputes between us.
|
||||
|
||||
## 10. Indemnification
|
||||
|
||||
To the fullest extent allowed by applicable law, you agree to indemnify, defend, and hold tldraw, its affiliates, officers, agents, employees, and partners harmless from and against any and all claims, liabilities, damages, losses and expenses (including attorneys’ fees) arising from or in any way related to any third party claims relating to (a) your use of the Website, and (b) your violation of these Terms.
|
||||
|
||||
## 11. Disclaimer of warranties
|
||||
|
||||
YOU HEREBY ACKNOWLEDGE THAT YOU ARE USING THE WEBSITE AT YOUR OWN RISK. THE WEBSITE AND TLDRAW CONTENT ARE ALL PROVIDED "AS IS," AND TLDRAW, ITS AFFILIATES AND ITS THIRD PARTY SERVICE PROVIDERS HEREBY DISCLAIM ANY AND ALL WARRANTIES, EXPRESS AND IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF ACCURACY, RELIABILITY, MERCHANTABILITY, NON-INFRINGEMENT, FITNESS FOR A PARTICULAR PURPOSE, AND ANY OTHER WARRANTY, CONDITION, GUARANTEE OR REPRESENTATION, WHETHER ORAL, IN WRITING OR IN ELECTRONIC FORM. TLDRAW, ITS AFFILIATES, AND ITS THIRD PARTY SERVICE PROVIDERS DO NOT REPRESENT OR WARRANT THAT ACCESS TO THE WEBSITE WILL BE UNINTERRUPTED OR THAT THERE WILL BE NO FAILURES, ERRORS OR OMISSIONS OR LOSS OF TRANSMITTED INFORMATION, OR THAT NO VIRUSES WILL BE TRANSMITTED THROUGH THE WEBSITE.
|
||||
|
||||
Because some states do not permit disclaimer of implied warranties, you may have additional rights under your local laws.
|
||||
|
||||
## 12. Limitation of liability
|
||||
|
||||
TO THE FULLEST EXTENT ALLOWED BY APPLICABLE LAW, UNDER NO CIRCUMSTANCES AND UNDER NO LEGAL THEORY (INCLUDING, WITHOUT LIMITATION, TORT, CONTRACT, STRICT LIABILITY, OR OTHERWISE) SHALL TLDRAW (OR ITS LICENSORS) BE LIABLE TO YOU OR TO ANY OTHER PERSON FOR (A) ANY INDIRECT, SPECIAL, INCIDENTAL, OR CONSEQUENTIAL DAMAGES OF ANY KIND, INCLUDING DAMAGES FOR LOST PROFITS, LOSS OF GOODWILL, WORK STOPPAGE, ACCURACY OF RESULTS, OR COMPUTER FAILURE OR MALFUNCTION, (B) ANY AMOUNT, IN THE AGGREGATE, IN EXCESS OF $20, OR (C) ANY MATTER BEYOND OUR REASONABLE CONTROL. SOME STATES DO NOT ALLOW THE EXCLUSION OR LIMITATION OF CERTAIN DAMAGES, SO THE ABOVE LIMITATION AND EXCLUSIONS MAY NOT APPLY TO YOU.
|
||||
|
||||
## 13. Notices
|
||||
|
||||
Any notices or other communications permitted or required hereunder will be in writing and given by tldraw by posting to the Website.
|
||||
|
||||
## 14. No waiver
|
||||
|
||||
The failure of tldraw to enforce any right or provision of these Terms will not constitute a waiver of future enforcement of that right or provision.
|
||||
|
||||
## 15. Assignment
|
||||
|
||||
You may not assign or transfer these Terms, by operation of law or otherwise, without tldraw’s prior written consent. Any attempt by you to assign or transfer these Terms without such consent will be null and of no effect. tldraw may assign or transfer these Terms, at its sole discretion, without restriction. Subject to the foregoing, these Terms will bind and inure to the benefit of the parties, their successors and permitted assigns. These Terms do not and are not intended to confer any rights or remedies upon any person or entity other than the parties.
|
||||
|
||||
## 16. Severability
|
||||
|
||||
If for any reason an arbitrator or a court of competent jurisdiction finds any provision of these Terms invalid or unenforceable, that provision will be enforced to the maximum extent permissible and the other provisions of these Terms will remain in full force and effect.
|
||||
|
||||
## 17. Governing law; arbitration
|
||||
|
||||
These Terms are governed by and will be construed under the laws of England and Wales, without regard to the conflicts of laws provisions thereof. The arbitration shall be held in accordance with the Streamlined Arbitration Rules and Procedures of Judicial Arbitration and Mediation Website, Inc. ("JAMS") then in effect, by one commercial arbitrator with substantial experience in resolving intellectual property and commercial contract disputes, who shall be selected from the appropriate list of JAMS arbitrators in accordance with such Rules. The arbitration shall be held in the jurisdiction of your most recent physical address or, if there is no such jurisdiction, in London, England. Judgment upon the award rendered by such arbitrator may be entered in any court of competent jurisdiction.
|
||||
|
||||
Notwithstanding the foregoing obligation to arbitrate disputes, each party shall have the right to pursue injunctive or other equitable relief at any time, from any court of competent jurisdiction. For all purposes of these Terms, the parties consent to exclusive jurisdiction and venue in English courts. Any arbitration under these Terms will take place on an individual basis: class arbitrations and class actions are not permitted. YOU UNDERSTAND AND AGREE THAT BY ENTERING INTO THESE TERMS, YOU AND TLDRAW ARE EACH WAIVING THE RIGHT TO TRIAL BY JURY OR TO PARTICIPATE IN A CLASS ACTION.
|
||||
|
||||
## 18. Entire agreement
|
||||
|
||||
These Terms constitute the entire agreement between you and tldraw regarding your use of the Website, and supersede all prior written or oral agreements.
|
||||
|
||||
## 19. Contact Us
|
||||
|
||||
If you have any questions about the Website, please do not hesitate to contact us at hello@tldraw.com.
|
||||
1
apps/dotcom/client/assets/icons/icon/icon-avatar.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><g clip-path="url(#a)"><path fill="#000" fill-rule="evenodd" d="M.879 7.5a6.623 6.623 0 1 1 13.246 0 6.623 6.623 0 0 1-13.246 0m6.623-5.673a5.673 5.673 0 0 0-4.193 9.494 4.97 4.97 0 0 1 4.193-2.296 4.97 4.97 0 0 1 4.193 2.296 5.673 5.673 0 0 0-4.193-9.494m3.482 10.152a4.02 4.02 0 0 0-3.482-2.004 4.02 4.02 0 0 0-3.482 2.004 5.65 5.65 0 0 0 3.482 1.194c1.312 0 2.52-.446 3.482-1.194M5.152 6.505a2.35 2.35 0 1 1 4.7 0 2.35 2.35 0 0 1-4.7 0m2.35-1.4a1.4 1.4 0 1 0 0 2.8 1.4 1.4 0 0 0 0-2.8" clip-rule="evenodd" style="fill:#000;fill-opacity:1"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h15v15H0z" style="fill:#fff;fill-opacity:1"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 739 B |
1
apps/dotcom/client/assets/icons/icon/icon-bookmark.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#000" d="M11.6 2.01a.5.5 0 0 1 .4.49v11a.5.5 0 0 1-.765.424L7.5 11.59l-3.735 2.334A.5.5 0 0 1 3 13.5v-11l.01-.1A.5.5 0 0 1 3.5 2h8zM4 12.598l2.97-1.857a1 1 0 0 1 1.06 0L11 12.598V3H4z"/></svg>
|
||||
|
After Width: | Height: | Size: 279 B |
1
apps/dotcom/client/assets/icons/icon/icon-check.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#000" fill-rule="evenodd" d="M11.672 3.727c.289.189.37.576.181.865l-4.25 6.5a.625.625 0 0 1-.943.12l-2.75-2.5a.625.625 0 0 1 .84-.925l2.208 2.007 3.849-5.886a.625.625 0 0 1 .865-.181" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 298 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#000" stroke="#000" stroke-width=".25" d="M3.25 5.198a.625.625 0 0 0 .028.883l4 3.75c.24.225.614.225.855 0l-.086-.091.086.091 4-3.75a.625.625 0 0 0-.855-.912l-3.573 3.35-3.572-3.35a.625.625 0 0 0-.884.029Z"/></svg>
|
||||
|
After Width: | Height: | Size: 301 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#000" fill-rule="evenodd" d="M3.34 5.283a.5.5 0 0 1 .707-.023l3.658 3.43 3.658-3.43a.5.5 0 0 1 .684.73l-4 3.75a.5.5 0 0 1-.684 0l-4-3.75a.5.5 0 0 1-.023-.707" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 273 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#000" stroke="#000" stroke-width=".25" d="M5.403 11.956a.625.625 0 0 0 .883-.028l3.75-4a.625.625 0 0 0 0-.855l-.091.085.091-.085-3.75-4a.625.625 0 0 0-.912.855L8.724 7.5l-3.35 3.573a.625.625 0 0 0 .029.883Z"/></svg>
|
||||
|
After Width: | Height: | Size: 302 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#000" fill-rule="evenodd" d="M5.488 11.865a.5.5 0 0 1-.023-.707L8.895 7.5l-3.43-3.658a.5.5 0 0 1 .73-.684l3.75 4a.5.5 0 0 1 0 .684l-3.75 4a.5.5 0 0 1-.707.023" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 274 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" d="m5 9 2.5 2.5L10 9M5 6l2.5-2.5L10 6"/></svg>
|
||||
|
After Width: | Height: | Size: 188 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#000" stroke="#000" stroke-width=".25" d="M12.161 9.802a.625.625 0 0 0-.028-.883l-4-3.75a.625.625 0 0 0-.855 0l.085.091-.085-.091-4 3.75a.625.625 0 0 0 .855.912l3.572-3.35 3.573 3.35a.625.625 0 0 0 .883-.029Z"/></svg>
|
||||
|
After Width: | Height: | Size: 304 B |
1
apps/dotcom/client/assets/icons/icon/icon-chevron-up.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#000" fill-rule="evenodd" d="M12.07 9.717a.5.5 0 0 1-.707.023L7.705 6.31 4.047 9.74a.5.5 0 0 1-.684-.73l4-3.75a.5.5 0 0 1 .684 0l4 3.75a.5.5 0 0 1 .023.707" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 271 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#000" d="M13.236 3.03 8.766 7.5l4.47 4.47a.75.75 0 0 1-1.061 1.06l-4.47-4.47-4.47 4.47a.75.75 0 0 1-1.06-1.06l4.47-4.47-4.47-4.47a.75.75 0 1 1 1.06-1.06l4.47 4.47 4.47-4.47a.75.75 0 0 1 1.06 1.06"/></svg>
|
||||
|
After Width: | Height: | Size: 291 B |
1
apps/dotcom/client/assets/icons/icon/icon-close.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#000" fill-rule="evenodd" d="M13.059 2.854a.5.5 0 0 0-.707-.708L7.704 6.793 3.06 2.146a.5.5 0 1 0-.707.708L6.998 7.5l-4.646 4.646a.5.5 0 0 0 .707.708l4.646-4.647 4.647 4.647a.5.5 0 0 0 .707-.708L8.412 7.5z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 321 B |
1
apps/dotcom/client/assets/icons/icon/icon-comment.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path stroke="#2E2E2E" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.25" d="M11 1.5c1.105 0 2.5.895 2.5 2v5c0 1.105-1.395 2-2.5 2H7l-3 3v-3c-1.105 0-2.5-.895-2.5-2v-5a2 2 0 0 1 2-2z"/></svg>
|
||||
|
After Width: | Height: | Size: 282 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><g fill="#000" fill-rule="evenodd" clip-path="url(#a)" clip-rule="evenodd"><path d="M2.705 11a1.5 1.5 0 0 1-1.5-1.5v-7a1.5 1.5 0 0 1 1.5-1.5h7a1.5 1.5 0 0 1 1.5 1.5V4h1.5a1.5 1.5 0 0 1 1.5 1.5v7a1.5 1.5 0 0 1-1.5 1.5h-7a1.5 1.5 0 0 1-1.5-1.5v-7a1.5 1.5 0 0 1 1.5-1.5h4.5V2.5a.5.5 0 0 0-.5-.5h-7a.5.5 0 0 0-.5.5v7a.5.5 0 0 0 .5.5h1.5v1zm3-6a.5.5 0 0 0-.5.5v7a.5.5 0 0 0 .5.5h7a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.5-.5z"/><path d="M2.705 11a1.5 1.5 0 0 1-1.5-1.5v-7a1.5 1.5 0 0 1 1.5-1.5h7a1.5 1.5 0 0 1 1.5 1.5V4h1.5a1.5 1.5 0 0 1 1.5 1.5v7a1.5 1.5 0 0 1-1.5 1.5h-7a1.5 1.5 0 0 1-1.5-1.5V11zm1.25.25h-1.25A1.75 1.75 0 0 1 .955 9.5v-7c0-.966.784-1.75 1.75-1.75h7c.967 0 1.75.784 1.75 1.75v1.25h1.25c.967 0 1.75.783 1.75 1.75v7a1.75 1.75 0 0 1-1.75 1.75h-7a1.75 1.75 0 0 1-1.75-1.75zm.25-1.25V5.5a1.5 1.5 0 0 1 1.5-1.5h4.5V2.5a.5.5 0 0 0-.5-.5h-7a.5.5 0 0 0-.5.5v7a.5.5 0 0 0 .5.5zm-.25-.25h-1.25a.25.25 0 0 1-.25-.25v-7a.25.25 0 0 1 .25-.25h7a.25.25 0 0 1 .25.25v1.25h-4.25a1.75 1.75 0 0 0-1.75 1.75zm1.75-4.5a.25.25 0 0 0-.25.25v7c0 .138.112.25.25.25h7a.25.25 0 0 0 .25-.25v-7a.25.25 0 0 0-.25-.25zm-.5.25a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-.5.5h-7a.5.5 0 0 1-.5-.5z"/></g><defs><clipPath id="a"><path fill="#fff" d="M.205 0h15v15h-15z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
apps/dotcom/client/assets/icons/icon/icon-copy.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#000" fill-rule="evenodd" d="M1.205 9.5a1.5 1.5 0 0 0 1.5 1.5h1.5v-1h-1.5a.5.5 0 0 1-.5-.5v-7a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 .5.5V4h-4.5a1.5 1.5 0 0 0-1.5 1.5v7a1.5 1.5 0 0 0 1.5 1.5h7a1.5 1.5 0 0 0 1.5-1.5v-7a1.5 1.5 0 0 0-1.5-1.5h-1.5V2.5a1.5 1.5 0 0 0-1.5-1.5h-7a1.5 1.5 0 0 0-1.5 1.5zm4-4a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 .5.5v7a.5.5 0 0 1-.5.5h-7a.5.5 0 0 1-.5-.5z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 481 B |
1
apps/dotcom/client/assets/icons/icon/icon-doc-strong.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#000" fill-rule="evenodd" d="M3.705 2.25a.25.25 0 0 0-.25.25v10c0 .138.112.25.25.25h8a.25.25 0 0 0 .25-.25V6.25h-3.25a.75.75 0 0 1-.75-.75V2.25zm5.75 1.06 1.44 1.44h-1.44zm-7.5-.81c0-.966.784-1.75 1.75-1.75h5a.75.75 0 0 1 .53.22l4 4c.141.14.22.331.22.53v7a1.75 1.75 0 0 1-1.75 1.75h-8a1.75 1.75 0 0 1-1.75-1.75z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 427 B |
1
apps/dotcom/client/assets/icons/icon/icon-doc.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#000" fill-rule="evenodd" d="M3.705 2a.5.5 0 0 0-.5.5v10a.5.5 0 0 0 .5.5h8a.5.5 0 0 0 .5-.5V6h-3.5a.5.5 0 0 1-.5-.5V2zm5.5.707L11.498 5H9.205zm-7-.207a1.5 1.5 0 0 1 1.5-1.5h5a.5.5 0 0 1 .354.146l4 4a.5.5 0 0 1 .146.354v7a1.5 1.5 0 0 1-1.5 1.5h-8a1.5 1.5 0 0 1-1.5-1.5z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 384 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#000" stroke="#000" stroke-width=".25" d="M10.915 8.298c.22.217.488.327.795.327q.308 0 .566-.153.251-.151.403-.404a1.1 1.1 0 0 0 .159-.57q-.001-.464-.333-.791a1.1 1.1 0 0 0-.795-.332 1.1 1.1 0 0 0-.795.332c-.22.217-.332.483-.332.79q0 .468.332.8Zm-4.005 0c.22.217.488.327.795.327q.308 0 .565-.153.252-.151.404-.404a1.1 1.1 0 0 0 .158-.57q0-.464-.333-.791a1.1 1.1 0 0 0-.794-.332 1.1 1.1 0 0 0-.795.332c-.22.217-.333.483-.333.79q0 .468.333.8Zm-4.005 0c.22.217.488.327.795.327q.309 0 .565-.153.252-.151.404-.404a1.1 1.1 0 0 0 .158-.57q-.001-.464-.332-.791a1.1 1.1 0 0 0-.795-.332 1.1 1.1 0 0 0-.795.332c-.22.217-.333.483-.333.79q0 .468.333.8Z"/></svg>
|
||||
|
After Width: | Height: | Size: 735 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#000" d="M11.71 8.5a.97.97 0 0 1-.707-.29.97.97 0 0 1-.296-.713q.001-.41.296-.701a.96.96 0 0 1 .707-.296q.411 0 .707.296a.95.95 0 0 1 .296.701.96.96 0 0 1-.14.507q-.136.225-.362.36a.96.96 0 0 1-.501.136m-4.005 0a.97.97 0 0 1-.707-.29.97.97 0 0 1-.296-.713q0-.41.296-.701a.97.97 0 0 1 .707-.296q.412 0 .706.296a.95.95 0 0 1 .296.701.96.96 0 0 1-.14.507q-.135.225-.361.36a.96.96 0 0 1-.501.136M3.7 8.5a.97.97 0 0 1-.707-.29.97.97 0 0 1-.296-.713q0-.41.296-.701A.97.97 0 0 1 3.7 6.5q.411 0 .707.296a.95.95 0 0 1 .295.701.96.96 0 0 1-.14.507q-.135.225-.361.36A.96.96 0 0 1 3.7 8.5"/></svg>
|
||||
|
After Width: | Height: | Size: 672 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#000" stroke="#000" stroke-width=".25" d="M6.907 10.71q-.326.333-.327.795 0 .309.153.566.151.252.404.403.261.159.57.159.464 0 .791-.333.331-.332.332-.795a1.1 1.1 0 0 0-.332-.795 1.07 1.07 0 0 0-.79-.332q-.468 0-.8.332Zm0-4.005c-.217.22-.327.488-.327.795q0 .309.153.566.151.251.404.403.261.159.57.159.464-.002.791-.333.331-.332.332-.795a1.1 1.1 0 0 0-.332-.795 1.07 1.07 0 0 0-.79-.333q-.468 0-.8.333Zm0-4.005c-.217.22-.327.488-.327.795q0 .309.153.565.151.252.404.404.261.159.57.158.464-.001.791-.332.331-.331.332-.795a1.1 1.1 0 0 0-.332-.795 1.07 1.07 0 0 0-.79-.333q-.468 0-.8.333Z"/></svg>
|
||||
|
After Width: | Height: | Size: 678 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#2E2E2E" d="M6.5 11.505q0-.411.29-.707a.97.97 0 0 1 .712-.295q.411 0 .702.295a.97.97 0 0 1 .296.707q0 .411-.296.707a.95.95 0 0 1-.702.296.96.96 0 0 1-.506-.14q-.225-.135-.36-.362a.96.96 0 0 1-.136-.5m0-4.006q0-.411.29-.707a.97.97 0 0 1 .712-.295q.411 0 .702.295A.97.97 0 0 1 8.5 7.5q0 .411-.296.707a.95.95 0 0 1-.702.296.96.96 0 0 1-.506-.14q-.225-.136-.36-.362a.96.96 0 0 1-.136-.5m0-4.006q0-.411.29-.707a.97.97 0 0 1 .712-.296q.411 0 .702.296a.97.97 0 0 1 .296.707q0 .411-.296.707a.95.95 0 0 1-.702.295.96.96 0 0 1-.506-.14q-.225-.135-.36-.36a.96.96 0 0 1-.136-.502" style="fill:#2e2e2e;fill:color(display-p3 .1804 .1804 .1804);fill-opacity:1"/></svg>
|
||||
|
After Width: | Height: | Size: 740 B |
1
apps/dotcom/client/assets/icons/icon/icon-draw.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><g clip-path="url(#a)"><path fill="#000" fill-rule="evenodd" d="M0 1.95A1.95 1.95 0 0 1 1.95 0h11.1A1.95 1.95 0 0 1 15 1.95v11.1A1.95 1.95 0 0 1 13.05 15H1.95A1.95 1.95 0 0 1 0 13.05zM8.85 4.5a1.35 1.35 0 1 1-2.7 0 1.35 1.35 0 0 1 2.7 0m-1.793 7.65c.635.002 1.246-.88 1.475-1.372.281-.606.457-1.696.175-2.342-.198-.452-.652-.786-1.251-.786-.721 0-1.306.586-1.306 1.31 0 .2.046.387.127.553.075.154.174.314.274.476.315.508.637 1.03.244 1.398-.318.3-.218.761.262.763" clip-rule="evenodd"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h15v15H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 649 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><g fill="#000" fill-rule="evenodd" clip-path="url(#a)" clip-rule="evenodd"><path d="M11.646.646a.5.5 0 0 1 .707 0l2 2a.5.5 0 0 1 0 .708l-3.942 3.942a1 1 0 0 1-.26.188L6.224 9.447a.5.5 0 0 1-.671-.67l1.964-3.928a1 1 0 0 1 .187-.26zM12 1.707l-3.59 3.59-1.037 2.076.254.254L9.704 6.59 13.293 3z"/><path d="M12.53.47a.75.75 0 0 0-1.06 0zm0 0 2 2a.75.75 0 0 1 0 1.06l-3.942 3.943a1.3 1.3 0 0 1-.325.234L6.335 9.671A.75.75 0 0 1 5.33 8.665l1.964-3.928q.091-.181.234-.325L11.47.47M6.224 9.447l3.927-1.963a1 1 0 0 0 .26-.188l3.943-3.942a.5.5 0 0 0 0-.708l-2-2a.5.5 0 0 0-.708 0L7.704 4.59a1 1 0 0 0-.187.26L5.553 8.776a.5.5 0 0 0 .67.671m2.392-4.003-.939 1.879 1.879-.94L12.939 3 12 2.06zm-.205-.148L7.373 7.373l.254.254L9.704 6.59 13.293 3 12 1.707z"/><path d="M7.5 1.5h-5a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h9a2 2 0 0 0 2-2v-5H12v5a.5.5 0 0 1-.5.5h-9a.5.5 0 0 1-.5-.5v-9a.5.5 0 0 1 .5-.5h5z"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h15v15H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
apps/dotcom/client/assets/icons/icon/icon-edit.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="currentColor" fill-rule="evenodd" d="M12.146 1.146a.5.5 0 0 1 .707 0l2 2a.5.5 0 0 1 0 .708l-3.942 3.942a1 1 0 0 1-.26.188L6.724 9.947a.5.5 0 0 1-.671-.67l1.963-3.928a1 1 0 0 1 .188-.26zm.354 1.061-3.59 3.59-1.037 2.076.254.254 2.077-1.038L13.793 3.5zM10 2 9 3H4.9c-.428 0-.72 0-.944.019-.22.018-.332.05-.41.09a1 1 0 0 0-.437.437c-.04.078-.072.19-.09.41C3 4.18 3 4.472 3 4.9v6.2c0 .428 0 .72.019.944.018.22.05.332.09.41a1 1 0 0 0 .437.437c.078.04.19.072.41.09.225.019.516.019.944.019h6.2c.428 0 .72 0 .944-.019.22-.018.332-.05.41-.09a1 1 0 0 0 .437-.437c.04-.078.072-.19.09-.41.019-.225.019-.516.019-.944V7l1-1v5.12c0 .403 0 .735-.022 1.006-.023.281-.072.54-.196.782a2 2 0 0 1-.874.874c-.243.124-.501.173-.782.196-.27.022-.603.022-1.005.022H4.88c-.403 0-.735 0-1.006-.022-.281-.023-.54-.072-.782-.196a2 2 0 0 1-.874-.874c-.124-.243-.173-.501-.196-.782C2 11.856 2 11.523 2 11.12V4.88c0-.403 0-.735.022-1.006.023-.281.072-.54.196-.782a2 2 0 0 1 .874-.874c.243-.124.501-.173.782-.196C4.144 2 4.477 2 4.88 2z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
1
apps/dotcom/client/assets/icons/icon/icon-export.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#2E2E2E" d="M5 4.5a.5.5 0 0 1 0 1H3.5c-.423 0-.648.105-.771.229-.124.123-.229.348-.229.771v5.52c0 .423.105.649.229.772.123.123.348.229.771.229h8l.15-.005c.327-.022.514-.116.621-.224.124-.123.229-.349.229-.771V6.5c0-.423-.105-.648-.229-.771-.123-.124-.348-.229-.771-.229H10a.5.5 0 0 1 0-1h1.5c.577 0 1.102.145 1.479.521.376.377.521.902.521 1.479v5.52c0 .578-.145 1.102-.521 1.479-.33.33-.773.482-1.265.515l-.214.007h-8c-.577 0-1.102-.145-1.479-.522-.376-.377-.521-.902-.521-1.479V6.5c0-.577.145-1.102.521-1.479.377-.376.902-.521 1.479-.521zM7.5.02a.5.5 0 0 1 .336.13l.037.038 2 2.25a.5.5 0 0 1-.747.665L8 1.835V8.75a.5.5 0 0 1-1 0V1.835L5.873 3.103a.5.5 0 0 1-.747-.666l2-2.25.037-.037A.5.5 0 0 1 7.5.02" style="fill:#2e2e2e;fill:color(display-p3 .1804 .1804 .1804);fill-opacity:1"/></svg>
|
||||
|
After Width: | Height: | Size: 875 B |
1
apps/dotcom/client/assets/icons/icon/icon-external.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#000" fill-rule="evenodd" d="M3 3.008a1 1 0 0 0-1 1V12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V8.5a.5.5 0 0 0-1 0V12H3V4.008h3.5a.5.5 0 0 0 0-1z" clip-rule="evenodd"/><path fill="#000" fill-rule="evenodd" d="M13 2.5v3a.5.5 0 0 1-1 0V3.707L6.854 8.854a.5.5 0 0 1-.708-.708L11.293 3H9.5a.5.5 0 0 1 0-1h3a.5.5 0 0 1 .5.497" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 425 B |
1
apps/dotcom/client/assets/icons/icon/icon-feedback.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" d="M5.45 11.5A4.5 4.5 0 1 0 3.5 9.55l-1 2.95z"/></svg>
|
||||
|
After Width: | Height: | Size: 196 B |
1
apps/dotcom/client/assets/icons/icon/icon-folder-new.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M7.5 7.07v3.606M5.625 8.873h3.75m3.125 4.902a1.2 1.2 0 0 0 .884-.398c.234-.254.366-.6.366-.96V5.634c0-.36-.132-.705-.366-.96a1.2 1.2 0 0 0-.884-.397H7.563a1.17 1.17 0 0 1-.6-.16 1.3 1.3 0 0 1-.457-.45L6 2.85a1.3 1.3 0 0 0-.45-.448 1.2 1.2 0 0 0-.594-.163H2.5a1.2 1.2 0 0 0-.884.398 1.42 1.42 0 0 0-.366.96v8.82c0 .36.132.705.366.96.235.254.552.397.884.397z"/></svg>
|
||||
|
After Width: | Height: | Size: 529 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" d="m3.749 9.31.938-1.687c.101-.188.256-.347.448-.46.192-.112.412-.174.639-.18h6.725m0 0c.191 0 .38.04.551.119s.322.191.439.332c.117.14.198.304.236.478.039.174.034.353-.014.526l-1.1 3.489c-.069.25-.227.473-.447.631a1.3 1.3 0 0 1-.771.241H2.499a1.3 1.3 0 0 1-.884-.34 1.12 1.12 0 0 1-.366-.823v-7.56c0-.309.132-.605.366-.823.235-.218.553-.34.884-.34h2.438c.209-.002.415.045.6.136.184.092.34.225.456.387l.506.698c.114.16.269.293.451.384s.386.14.593.14h3.706c.332 0 .65.122.884.34s.366.514.366.822z"/></svg>
|
||||
|
After Width: | Height: | Size: 645 B |
1
apps/dotcom/client/assets/icons/icon/icon-folder.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" d="M12.5 12.638c.332 0 .65-.12.884-.336s.366-.508.366-.813V5.744c0-.304-.132-.597-.366-.812a1.3 1.3 0 0 0-.884-.336H7.563a1.34 1.34 0 0 1-.6-.135 1.2 1.2 0 0 1-.457-.383L6 3.39a1.2 1.2 0 0 0-.45-.38 1.34 1.34 0 0 0-.594-.137H2.5c-.332 0-.65.121-.884.337a1.1 1.1 0 0 0-.366.812v7.468c0 .305.132.597.366.813.235.215.552.336.884.336z"/></svg>
|
||||
|
After Width: | Height: | Size: 481 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><g fill="#000" stroke="#000" stroke-width=".25" clip-path="url(#a)"><path d="M9.037 6.034a3.23 3.23 0 0 0-1.48-2.073 1.975 1.975 0 1 1 1.48 2.073ZM9.525.895a3.226 3.226 0 0 0-3.17 2.634A3.225 3.225 0 1 0 9.056 7.31q.23.033.468.034a3.225 3.225 0 0 0 0-6.45ZM3.91 6.72a1.975 1.975 0 1 1 3.95 0 1.975 1.975 0 0 1-3.95 0Z"/><path stroke-linecap="round" d="M8.609 7.436q.44-.09.916-.091c2.31 0 4.055 1.65 4.055 3.535a.625.625 0 1 0 1.25 0c0-2.71-2.447-4.785-5.305-4.785q-.6.001-1.164.115a.625.625 0 1 0 .248 1.226ZM1.83 13.48c0-1.886 1.745-3.535 4.055-3.535s4.055 1.65 4.055 3.535a.625.625 0 1 0 1.25 0c0-2.71-2.447-4.785-5.305-4.785S.58 10.77.58 13.48a.625.625 0 1 0 1.25 0Z"/></g><defs><clipPath id="a"><path fill="#fff" d="M.205 0h15v15h-15z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 841 B |
1
apps/dotcom/client/assets/icons/icon/icon-group.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><g clip-path="url(#a)"><path fill="#000" fill-rule="evenodd" d="M7.427 4.03A2.1 2.1 0 1 1 8.93 6.135 3.1 3.1 0 0 0 7.427 4.03m-.97-.357a3.1 3.1 0 1 1 2.496 3.494 3.1 3.1 0 1 1-2.496-3.494M3.785 6.72a2.1 2.1 0 1 1 4.2 0 2.1 2.1 0 0 1-4.2 0" clip-rule="evenodd"/><path stroke="#000" stroke-linecap="round" d="M14.205 10.88c0-2.298-2.095-4.16-4.68-4.16q-.537.001-1.04.103m2.08 6.657c0-2.298-2.095-4.16-4.68-4.16s-4.68 1.862-4.68 4.16"/></g><defs><clipPath id="a"><path fill="#fff" d="M.205 0h15v15h-15z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 601 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><g clip-path="url(#a)"><path fill="#000" d="M7.499.877a6.623 6.623 0 1 1 0 13.246A6.623 6.623 0 0 1 7.5.877m0 .95a5.674 5.674 0 1 0 0 11.347 5.674 5.674 0 0 0 0-11.347m0 7.923a.75.75 0 1 1 0 1.5.75.75 0 0 1 0-1.5m0-5.924c1.435 0 2.55 1.103 2.55 2.424 0 1.104-.73 1.64-1.265 1.965-.3.182-.48.272-.635.392a.5.5 0 0 0-.1.097.55.55 0 0 1-1.099-.003c0-.468.282-.773.525-.962.22-.172.54-.341.74-.463.465-.283.735-.535.735-1.026 0-.677-.585-1.324-1.45-1.325s-1.45.648-1.45 1.325a.55.55 0 0 1-1.1 0c0-1.322 1.115-2.424 2.55-2.424"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h15v15H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 687 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#000" stroke="#000" stroke-width=".25" d="m14.963 7.748-6.75-6.64a.725.725 0 0 0-1.016 0l-6.75 6.64a.725.725 0 1 0 1.016 1.034l.617-.607v5.3c0 .345.28.625.625.625h10c.345 0 .625-.28.625-.625v-5.3l.617.607a.725.725 0 0 0 1.017-1.034ZM3.33 6.946l4.375-4.304 4.375 4.304v5.904h-1.75V9.475a.625.625 0 0 0-.625-.625h-3a.625.625 0 0 0-.625.625v3.375H3.33zM9.08 10.1v2.75H7.33V10.1z"/></svg>
|
||||
|
After Width: | Height: | Size: 471 B |
1
apps/dotcom/client/assets/icons/icon/icon-home.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><g clip-path="url(#a)"><path fill="#000" fill-rule="evenodd" d="M7.284 1.197a.6.6 0 0 1 .842 0l6.75 6.64a.6.6 0 0 1-.842.856l-.829-.816v5.598a.5.5 0 0 1-.5.5h-10a.5.5 0 0 1-.5-.5V7.877l-.83.816a.6.6 0 1 1-.84-.856zm.421 1.27 4.5 4.426v6.082h-2v-3.5a.5.5 0 0 0-.5-.5h-3a.5.5 0 0 0-.5.5v3.5h-3V6.893zm-.5 10.508h2v-3h-2z" clip-rule="evenodd"/></g><defs><clipPath id="a"><path fill="#fff" d="M.205 0h15v15h-15z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 509 B |
1
apps/dotcom/client/assets/icons/icon/icon-import.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#000" fill-rule="evenodd" d="M3 2a1 1 0 0 0-1 1v9a1 1 0 0 0 1 1h9a1 1 0 0 0 1-1V8.5a.5.5 0 0 0-1 0V12H3V3h3.5a.5.5 0 0 0 0-1zm3 6.5v-3a.5.5 0 0 1 1 0v1.793l5.146-5.147a.5.5 0 0 1 .708.708L7.707 8H9.5a.5.5 0 0 1 0 1h-3a.5.5 0 0 1-.5-.497" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 352 B |
1
apps/dotcom/client/assets/icons/icon/icon-info.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><g clip-path="url(#a)"><path fill="#000" fill-rule="evenodd" d="M7.5.877a6.623 6.623 0 1 0 0 13.246A6.623 6.623 0 0 0 7.5.877M1.827 7.5a5.673 5.673 0 1 1 11.346 0 5.673 5.673 0 0 1-11.346 0m6.423-3a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0M6 6h1.5a.5.5 0 0 1 .5.5V10h1v1H6v-1h1V7H6z" clip-rule="evenodd"/></g><defs><clipPath id="a"><path fill="#fff" d="M.205 0h15v15h-15z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 469 B |
1
apps/dotcom/client/assets/icons/icon/icon-invite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path stroke="#000" stroke-linecap="round" stroke-linejoin="round" d="M6.5 8a2.5 2.5 0 0 0 3.77.27l1.5-1.5a2.5 2.5 0 0 0-3.535-3.535l-.86.855M8.5 7a2.5 2.5 0 0 0-3.77-.27l-1.5 1.5a2.5 2.5 0 0 0 3.535 3.535l.855-.855"/></svg>
|
||||
|
After Width: | Height: | Size: 299 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#000" fill-rule="evenodd" d="M8.858 3.212c.597-.405 1.342-.408 1.889-.049.117.077.25.204.67.625.421.42.548.553.625.67.36.547.357 1.293-.049 1.89-.088.13-.229.275-.664.71l-.619.62a.75.75 0 1 0 1.061 1.06l.619-.619.056-.056c.356-.356.61-.609.788-.872.73-1.074.77-2.477.062-3.556-.173-.263-.422-.512-.76-.85l-.058-.058-.057-.057c-.338-.339-.587-.587-.85-.76-1.08-.709-2.483-.668-3.556.061-.264.18-.516.432-.873.789l-.056.056-.618.618a.75.75 0 0 0 1.06 1.061l.62-.619c.434-.435.58-.576.71-.664M4.7 7.324a.75.75 0 1 0-1.06-1.061l-.62.619-.055.056c-.357.356-.61.609-.789.872-.729 1.073-.77 2.477-.062 3.555.173.264.422.513.76.85l.058.058.058.058c.338.338.586.587.85.76 1.079.709 2.482.667 3.556-.062.263-.179.516-.432.872-.788l.056-.056.619-.619a.75.75 0 0 0-1.061-1.06l-.619.618c-.435.435-.58.576-.71.664-.597.406-1.343.409-1.89.05-.117-.078-.25-.205-.67-.625s-.548-.554-.625-.671c-.359-.547-.356-1.292.05-1.89.087-.129.228-.275.663-.71zm5.304-1.062a.75.75 0 1 0-1.061-1.06L5.407 8.737a.75.75 0 0 0 1.061 1.061z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
1
apps/dotcom/client/assets/icons/icon/icon-link.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#000" fill-rule="evenodd" d="M8.717 3.005c.676-.46 1.532-.468 2.167-.051.144.095.298.245.71.657s.563.566.657.71c.418.636.409 1.49-.051 2.167-.105.154-.267.32-.694.747l-.619.619a.5.5 0 0 0 .707.707l.619-.619.043-.043c.37-.37.606-.606.771-.849.675-.994.71-2.288.06-3.278-.159-.241-.39-.472-.741-.824l-.045-.044-.044-.045c-.352-.351-.583-.582-.824-.74-.99-.651-2.284-.616-3.278.059-.243.165-.48.402-.849.771l-.043.043-.619.619a.5.5 0 1 0 .708.707L7.97 3.7c.427-.427.593-.589.747-.694m-4.194 4.14a.5.5 0 0 0-.707-.707l-.619.62-.043.042c-.37.37-.606.606-.77.85-.676.993-.711 2.287-.06 3.277.158.241.389.472.74.824l.045.044.045.045c.35.351.582.582.823.741.99.65 2.284.615 3.278-.06.243-.165.48-.402.85-.771l.042-.043.619-.619a.5.5 0 1 0-.707-.707l-.619.619c-.427.427-.593.59-.747.694-.676.46-1.531.468-2.167.051-.144-.095-.298-.245-.71-.657s-.562-.566-.657-.71c-.417-.635-.408-1.49.051-2.167.105-.154.267-.32.694-.747zm5.304-1.06a.5.5 0 1 0-.707-.708L5.584 8.914a.5.5 0 1 0 .707.707z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
1
apps/dotcom/client/assets/icons/icon/icon-manual.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#2E2E2E" fill-rule="evenodd" d="M9.261 1.497c1.072-.266 2.248-.247 2.73-.225a.74.74 0 0 1 .704.743v.245h1.055a.75.75 0 0 1 .75.75v9.503a.75.75 0 0 1-.75.75H1.25a.75.75 0 0 1-.75-.75V3.01a.75.75 0 0 1 .75-.75h1.055v-.245l.003-.072a.74.74 0 0 1 .702-.671l.211-.008c.562-.015 1.58 0 2.518.232.535.133 1.088.347 1.516.705q.132.11.245.238.113-.128.246-.238c.427-.358.98-.572 1.515-.705m3.433 9.479-.004.08a.76.76 0 0 1-.786.675c-.486-.02-1.533-.025-2.441.208a3.3 3.3 0 0 0-.833.324h4.87V3.26h-.805zM1.5 3.26v9.003h4.87a3.3 3.3 0 0 0-.833-.324c-.795-.204-1.696-.225-2.235-.214l-.207.006a.76.76 0 0 1-.786-.675l-.004-.08V3.26zm1.805-.997v8.462c.584-.011 1.574.013 2.48.246.419.107.847.265 1.215.505V3.778a1 1 0 0 0-.386-.81c-.263-.22-.651-.386-1.116-.501-.785-.195-1.664-.215-2.193-.204m8.39 0c-.53-.01-1.408.009-2.193.204-.464.115-.852.281-1.115.501a1 1 0 0 0-.38.682L8 3.778v7.698c.368-.24.796-.398 1.214-.505.907-.233 1.896-.257 2.48-.246z" clip-rule="evenodd" style="fill:#2e2e2e;fill:color(display-p3 .1804 .1804 .1804);fill-opacity:1"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
1
apps/dotcom/client/assets/icons/icon/icon-more.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#000" d="M11.505 8.5a.97.97 0 0 1-.707-.29.97.97 0 0 1-.296-.713q0-.41.296-.701a.96.96 0 0 1 .707-.296q.411 0 .707.296a.95.95 0 0 1 .296.701.96.96 0 0 1-.14.507q-.136.225-.362.36a.96.96 0 0 1-.501.136M7.5 8.5a.97.97 0 0 1-.707-.29.97.97 0 0 1-.296-.713q0-.41.296-.701A.97.97 0 0 1 7.5 6.5q.412 0 .706.296a.95.95 0 0 1 .296.701.96.96 0 0 1-.14.507q-.135.225-.361.36A.96.96 0 0 1 7.5 8.5m-4.005 0a.97.97 0 0 1-.707-.29.97.97 0 0 1-.296-.713q0-.41.296-.701a.97.97 0 0 1 .707-.296q.412 0 .706.296a.95.95 0 0 1 .296.701.96.96 0 0 1-.14.507q-.135.225-.361.36a.96.96 0 0 1-.501.136"/></svg>
|
||||
|
After Width: | Height: | Size: 670 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path stroke="#2E2E2E" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6.5a4.5 4.5 0 0 0-9 0c0 4.5-2 6-2 6h13s-2-1.5-2-6m-3.2 6.45a1.3 1.3 0 0 1-2.6 0"/></svg>
|
||||
|
After Width: | Height: | Size: 259 B |
1
apps/dotcom/client/assets/icons/icon/icon-pin-strong.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#000" fill-rule="evenodd" d="M9.836 1.97a.75.75 0 0 0-1.06 1.06l.449.45L4.78 6.813l-.954-.954a.75.75 0 0 0-1.06 1.06l1.413 1.415.884.884L1.97 12.31a.75.75 0 0 0 1.06 1.06l3.094-3.093.884.884 1.414 1.414a.75.75 0 1 0 1.06-1.06l-.954-.955 3.334-4.444.45.449a.75.75 0 0 0 1.06-1.06l-1.06-1.061-1.415-1.415zM5.852 7.884l4.444-3.333.495.494L7.457 9.49z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 463 B |
1
apps/dotcom/client/assets/icons/icon/icon-pin.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#2E2E2E" d="M8.952 2.146a.5.5 0 0 1 .707 0l3.536 3.536a.5.5 0 0 1-.707.707l-.653-.653-3.637 4.849 1.108 1.107a.5.5 0 1 1-.707.707L6.124 9.925l-3.27 3.27a.5.5 0 1 1-.708-.707l3.27-3.27-2.474-2.475a.5.5 0 1 1 .707-.707l1.107 1.107 4.849-3.637-.653-.653a.5.5 0 0 1 0-.707M5.471 7.857l2.013 2.014 3.637-4.85-.802-.8z" style="fill:#2e2e2e;fill:color(display-p3 .1804 .1804 .1804);fill-opacity:1"/></svg>
|
||||
|
After Width: | Height: | Size: 485 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#000" stroke="#000" stroke-width=".25" d="M8.33 2.75a.625.625 0 0 0-1.25 0v4.125H2.955a.625.625 0 1 0 0 1.25H7.08v4.125a.625.625 0 0 0 1.25 0V8.125h4.125a.625.625 0 0 0 0-1.25H8.33z"/></svg>
|
||||
|
After Width: | Height: | Size: 277 B |
1
apps/dotcom/client/assets/icons/icon/icon-plus.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#000" fill-rule="evenodd" d="M8.205 2.75a.5.5 0 0 0-1 0V7h-4.25a.5.5 0 0 0 0 1h4.25v4.25a.5.5 0 0 0 1 0V8h4.25a.5.5 0 0 0 0-1h-4.25z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 248 B |
1
apps/dotcom/client/assets/icons/icon/icon-qr.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#000" fill-rule="evenodd" d="M3.005 1h-.05c-.229 0-.426 0-.6.041A1.5 1.5 0 0 0 1.247 2.15c-.042.174-.042.371-.042.6v2.5c0 .229 0 .426.042.6a1.5 1.5 0 0 0 1.108 1.11c.174.041.371.041.6.041h2.5c.229 0 .426 0 .6-.041a1.5 1.5 0 0 0 1.109-1.11c.042-.174.041-.371.041-.6v-2.5c0-.229 0-.426-.041-.6a1.5 1.5 0 0 0-1.109-1.11C5.881 1 5.684 1 5.455 1zm-.417 1.014c.043-.01.11-.014.417-.014h2.4c.308 0 .374.003.417.014a.5.5 0 0 1 .37.37c.01.042.013.108.013.416v2.4c0 .308-.003.374-.014.417a.5.5 0 0 1-.37.37c-.042.01-.108.013-.416.013h-2.4c-.308 0-.374-.003-.417-.014a.5.5 0 0 1-.369-.37c-.01-.042-.014-.108-.014-.416V2.8c0-.308.004-.374.014-.417a.5.5 0 0 1 .37-.37M10.005 1h-.05c-.229 0-.426 0-.6.041A1.5 1.5 0 0 0 8.247 2.15c-.042.174-.042.371-.042.6v2.5c0 .229 0 .426.042.6a1.5 1.5 0 0 0 1.108 1.11c.174.041.371.041.6.041h2.5c.229 0 .426 0 .6-.041a1.5 1.5 0 0 0 1.109-1.109c.042-.174.041-.371.041-.6v-2.5c0-.229 0-.426-.041-.6a1.5 1.5 0 0 0-1.109-1.109C12.881 1 12.684 1 12.455 1zm-.417 1.014c.043-.01.11-.014.417-.014h2.4c.308 0 .374.003.417.014a.5.5 0 0 1 .37.37c.01.042.013.108.013.416v2.4c0 .308-.003.374-.014.417a.5.5 0 0 1-.37.37c-.042.01-.108.013-.416.013h-2.4c-.308 0-.374-.003-.417-.014a.5.5 0 0 1-.369-.37c-.01-.042-.014-.108-.014-.416V2.8c0-.308.004-.374.014-.417a.5.5 0 0 1 .37-.37M2.955 8h2.5c.229 0 .426 0 .6.041A1.5 1.5 0 0 1 7.164 9.15c.042.174.041.371.041.6v2.5c0 .229 0 .426-.041.6a1.5 1.5 0 0 1-1.109 1.109c-.174.041-.371.041-.6.041h-2.5c-.229 0-.426 0-.6-.041a1.5 1.5 0 0 1-1.108-1.109c-.042-.174-.042-.371-.042-.6v-2.5c0-.229 0-.426.042-.6a1.5 1.5 0 0 1 1.108-1.11c.174-.04.371-.04.6-.04m.05 1c-.308 0-.374.003-.417.014a.5.5 0 0 0-.369.37c-.01.042-.014.108-.014.416v2.4c0 .308.004.374.014.417a.5.5 0 0 0 .37.37c.042.01.108.013.416.013h2.4c.308 0 .374-.004.417-.014a.5.5 0 0 0 .37-.37c.01-.042.013-.108.013-.416V9.8c0-.308-.003-.374-.014-.417a.5.5 0 0 0-.37-.37C5.78 9.004 5.714 9 5.406 9zm7-1h-.05c-.229 0-.426 0-.6.041A1.5 1.5 0 0 0 8.247 9.15c-.042.174-.042.371-.042.6v2.5c0 .229 0 .426.042.6a1.5 1.5 0 0 0 1.108 1.109c.174.041.371.041.6.041h2.5c.229 0 .426 0 .6-.041a1.5 1.5 0 0 0 1.109-1.109c.042-.174.041-.371.041-.6v-2.5c0-.229 0-.426-.041-.6a1.5 1.5 0 0 0-1.109-1.109C12.881 8 12.684 8 12.455 8zm-.417 1.014c.043-.01.11-.014.417-.014h2.4c.308 0 .374.003.417.014a.5.5 0 0 1 .37.37c.01.042.013.108.013.416v2.4c0 .308-.003.374-.014.417a.5.5 0 0 1-.37.37c-.042.01-.108.013-.416.013h-2.4c-.308 0-.374-.004-.417-.014a.5.5 0 0 1-.369-.37c-.01-.042-.014-.108-.014-.416V9.8c0-.308.004-.374.014-.417a.5.5 0 0 1 .37-.37" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#000" fill-rule="evenodd" d="M1.082 7.5a6.623 6.623 0 1 1 13.246 0 6.623 6.623 0 0 1-13.246 0m6.623-5.673a5.673 5.673 0 1 0 0 11.346 5.673 5.673 0 0 0 0-11.346m.75 8.673a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m-2.2-4.25c0-.678.585-1.325 1.45-1.325s1.45.647 1.45 1.325c0 .491-.27.742-.736 1.025l-.176.104c-.183.106-.4.232-.564.36-.242.188-.524.493-.524.961a.55.55 0 0 0 1.1.004.4.4 0 0 1 .1-.098c.102-.079.215-.144.366-.232q.116-.067.27-.159c.534-.325 1.264-.861 1.264-1.965 0-1.322-1.115-2.425-2.55-2.425s-2.55 1.103-2.55 2.425a.55.55 0 1 0 1.1 0" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 660 B |
1
apps/dotcom/client/assets/icons/icon/icon-question.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#000" d="M7.501 10.336a.621.621 0 1 1-.002 1.242.621.621 0 0 1 .002-1.242M7.5 3.502c1.153 0 2.428.907 2.429 2.275 0 1.127-.656 1.693-1.12 2.104-.466.413-.734.67-.734 1.233a.575.575 0 0 1-1.15 0c0-1.122.66-1.684 1.12-2.093.464-.41.734-.671.734-1.244 0-.585-.602-1.124-1.279-1.124s-1.278.54-1.278 1.124a.575.575 0 0 1-1.15 0c0-1.368 1.275-2.274 2.428-2.275"/></svg>
|
||||
|
After Width: | Height: | Size: 450 B |
1
apps/dotcom/client/assets/icons/icon/icon-refresh.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#2E2E2E" d="M7.5 1.5a.47.47 0 0 1 .5-.48 6.5 6.5 0 0 1 1.977 12.49.47.47 0 0 1-.623-.303.537.537 0 0 1 .313-.65A5.501 5.501 0 0 0 8 2.021a.537.537 0 0 1-.499-.523M7.5 13.5a.47.47 0 0 1-.499.48A6.5 6.5 0 0 1 5.023 1.49a.47.47 0 0 1 .623.303.537.537 0 0 1-.313.65A5.501 5.501 0 0 0 7 12.979a.537.537 0 0 1 .499.523" style="fill:#2e2e2e;fill:color(display-p3 .1804 .1804 .1804);fill-opacity:1"/><path fill="#2E2E2E" d="M2.75 2a.5.5 0 0 1 0-1H5.5a.5.5 0 0 1 .5.5V4a.5.5 0 1 1-1 0V2zm9.5 11a.5.5 0 1 1 0 1H9.5a.5.5 0 0 1-.5-.5V11a.5.5 0 0 1 1 0v2z" style="fill:#2e2e2e;fill:color(display-p3 .1804 .1804 .1804);fill-opacity:1"/></svg>
|
||||
|
After Width: | Height: | Size: 715 B |
1
apps/dotcom/client/assets/icons/icon/icon-search.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="currentColor" fill-rule="evenodd" d="M10 6.5a3.5 3.5 0 1 1-7 0 3.5 3.5 0 0 1 7 0m-.691 3.516a4.5 4.5 0 1 1 .707-.707l2.838 2.837a.5.5 0 0 1-.708.708z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 265 B |
1
apps/dotcom/client/assets/icons/icon/icon-settings.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="currentColor" fill-rule="evenodd" d="M7.07.65a.85.85 0 0 0-.828.662l-.238 1.05q-.57.167-1.08.448l-.91-.574a.85.85 0 0 0-1.055.118l-.606.606a.85.85 0 0 0-.118 1.054l.574.912q-.28.509-.447 1.079l-1.05.238a.85.85 0 0 0-.662.829v.857a.85.85 0 0 0 .662.829l1.05.238q.166.57.448 1.08l-.575.91a.85.85 0 0 0 .118 1.055l.607.606a.85.85 0 0 0 1.054.118l.911-.574q.51.28 1.079.447l.238 1.05a.85.85 0 0 0 .829.662h.857a.85.85 0 0 0 .829-.662l.238-1.05q.57-.166 1.08-.447l.911.574a.85.85 0 0 0 1.054-.118l.606-.606a.85.85 0 0 0 .118-1.054l-.574-.911q.282-.51.448-1.08l1.05-.238a.85.85 0 0 0 .662-.829v-.857a.85.85 0 0 0-.662-.83l-1.05-.237q-.166-.57-.447-1.08l.574-.91a.85.85 0 0 0-.118-1.055l-.606-.606a.85.85 0 0 0-1.055-.118l-.91.574a5.3 5.3 0 0 0-1.08-.448l-.239-1.05A.85.85 0 0 0 7.928.65zM4.92 3.813a4.5 4.5 0 0 1 1.795-.745L7.071 1.5h.857l.356 1.568a4.5 4.5 0 0 1 1.795.744l1.36-.857.607.606-.858 1.36c.37.527.628 1.136.744 1.795l1.568.356v.857l-1.568.355a4.5 4.5 0 0 1-.744 1.796l.857 1.36-.606.606-1.36-.857a4.5 4.5 0 0 1-1.795.743L7.928 13.5h-.857l-.356-1.568a4.5 4.5 0 0 1-1.794-.744l-1.36.858-.607-.606.858-1.36a4.5 4.5 0 0 1-.744-1.796L1.5 7.93v-.857l1.568-.356a4.5 4.5 0 0 1 .744-1.794L2.954 3.56l.606-.606zM9.026 7.5a1.525 1.525 0 1 1-3.05 0 1.525 1.525 0 0 1 3.05 0m.9 0a2.425 2.425 0 1 1-4.85 0 2.425 2.425 0 0 1 4.85 0" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
1
apps/dotcom/client/assets/icons/icon/icon-share.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#000" fill-rule="evenodd" d="M5 7.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0m.713 1.164a2.5 2.5 0 1 1 0-2.328l3.391-2.12A2.5 2.5 0 1 1 14 3.5a2.5 2.5 0 0 1-4.484 1.52l-3.53 2.207a2.5 2.5 0 0 1 0 .546l3.53 2.206a2.5 2.5 0 1 1-.411.804zM11.5 5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3m1.5 6.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 432 B |
1
apps/dotcom/client/assets/icons/icon/icon-shared.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#2E2E2E" d="M9.222 1.585a2.959 2.959 0 1 1-.519 5.872A2.959 2.959 0 1 1 6.297 4.09a2.96 2.96 0 0 1 2.925-2.505m-3.443 3.46a1.96 1.96 0 1 0 0 3.918 1.96 1.96 0 0 0 0-3.919m3.443-2.46a1.96 1.96 0 0 0-1.957 1.86 2.96 2.96 0 0 1 1.416 1.982q.258.075.54.076a1.959 1.959 0 1 0 0-3.918" style="fill:#2e2e2e;fill:color(display-p3 .1804 .1804 .1804);fill-opacity:1"/><path fill="#2E2E2E" d="M13.148 10.937c0-1.843-1.701-3.434-3.926-3.434q-.459 0-.886.088l-.05.007a.5.5 0 0 1-.147-.987l.264-.047q.4-.06.819-.061c2.663 0 4.926 1.932 4.926 4.434a.5.5 0 0 1-1 0m-3.443 2.459c0-1.843-1.7-3.434-3.926-3.434s-3.926 1.591-3.926 3.434a.5.5 0 1 1-1 0c0-2.502 2.263-4.434 4.926-4.434s4.926 1.931 4.926 4.434a.5.5 0 0 1-1 0" style="fill:#2e2e2e;fill:color(display-p3 .1804 .1804 .1804);fill-opacity:1"/></svg>
|
||||
|
After Width: | Height: | Size: 875 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><rect width="11.5" height="11.5" x="1.75" y="1.75" stroke="#000" stroke-width="1.5" rx="1.25"/><path fill="#000" d="M5 1h1.5v13H5z"/></svg>
|
||||
|
After Width: | Height: | Size: 214 B |
1
apps/dotcom/client/assets/icons/icon/icon-sidebar.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#2E2E2E" d="M12.143 12.607V14H2.857v-1.393zm.464-.464V2.857a.464.464 0 0 0-.464-.464H2.857a.464.464 0 0 0-.464.464v9.286c0 .256.208.464.464.464V14l-.095-.002a1.857 1.857 0 0 1-1.76-1.76L1 12.143V2.857c0-.994.78-1.805 1.762-1.855L2.857 1h9.286l.095.002c.982.05 1.762.861 1.762 1.855v9.286l-.002.095a1.857 1.857 0 0 1-1.76 1.76l-.095.002v-1.393a.464.464 0 0 0 .464-.464" style="fill:#2e2e2e;fill:color(display-p3 .1804 .1804 .1804);fill-opacity:1"/><path fill="#2E2E2E" d="M5 1h1.5v13H5z" style="fill:#2e2e2e;fill:color(display-p3 .1804 .1804 .1804);fill-opacity:1"/></svg>
|
||||
|
After Width: | Height: | Size: 658 B |
1
apps/dotcom/client/assets/icons/icon/icon-sign-in.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#2E2E2E" d="M6.792 4.694a.5.5 0 0 1 .665-.084l.04.031 2.321 2 .04.038a.5.5 0 0 1-.04.72l-2.321 2a.5.5 0 0 1-.653-.758l1.302-1.12H1a.5.5 0 1 1 0-1h7.146L6.844 5.398l-.037-.035a.5.5 0 0 1-.016-.67m5.5 9.807h-6.29a.5.5 0 0 1 0-1h6.29c.488 0 .763-.125.922-.29.162-.168.287-.464.287-.982V2.776h1v9.452c0 .663-.16 1.253-.567 1.676-.411.426-.99.596-1.642.596m0-12.996h-6.29a.5.5 0 0 1 0-1h6.29l.122.002c.601.02 1.134.195 1.52.594.407.423.567 1.013.567 1.676h-1c0-.518-.125-.814-.287-.982-.139-.144-.367-.258-.749-.284z" style="fill:#2e2e2e;fill:color(display-p3 .1804 .1804 .1804);fill-opacity:1"/></svg>
|
||||
|
After Width: | Height: | Size: 684 B |
1
apps/dotcom/client/assets/icons/icon/icon-spinner.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><g stroke="#000" stroke-width="2" clip-path="url(#a)"><circle cx="7.705" cy="7.5" r="6" opacity=".2"/><path stroke-linecap="round" d="M13.705 7.5a6 6 0 1 0-6 6"/></g><defs><clipPath id="a"><path fill="#fff" d="M.205 0h15v15h-15z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 330 B |
1
apps/dotcom/client/assets/icons/icon/icon-star-fill.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><g clip-path="url(#a)"><path fill="#000" d="M7.428.666a.3.3 0 0 1 .554 0L9.618 4.6a.3.3 0 0 0 .254.184l4.247.34a.3.3 0 0 1 .171.528l-3.236 2.772a.3.3 0 0 0-.097.297l.99 4.145a.3.3 0 0 1-.45.326L7.862 10.97a.3.3 0 0 0-.312 0l-3.637 2.222a.3.3 0 0 1-.448-.326l.989-4.145a.3.3 0 0 0-.097-.297L1.12 5.652a.3.3 0 0 1 .171-.527l4.248-.34a.3.3 0 0 0 .253-.185z"/></g><defs><clipPath id="a"><path fill="#fff" d="M.205 0h15v15h-15z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 524 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#000" stroke="#000" stroke-width=".25" d="M8.097.618a.425.425 0 0 0-.784 0L5.676 4.552a.18.18 0 0 1-.147.108L1.28 5c-.377.03-.53.5-.242.747l3.236 2.772c.05.042.071.11.056.173l-.989 4.145a.425.425 0 0 0 .635.461l3.637-2.22a.18.18 0 0 1 .182 0l3.637 2.22a.425.425 0 0 0 .635-.461l-.989-4.145a.18.18 0 0 1 .056-.173l3.236-2.772A.425.425 0 0 0 14.13 5l-4.248-.34a.18.18 0 0 1-.147-.108z"/></svg>
|
||||
|
After Width: | Height: | Size: 478 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#000" fill-rule="evenodd" d="M6.926.897c.359-.863 1.58-.863 1.939 0l1.53 3.68 3.974.319c.931.075 1.309 1.236.6 1.844L11.94 9.333l.925 3.878c.217.908-.772 1.626-1.569 1.14l-3.402-2.078-3.401 2.078c-.797.486-1.786-.232-1.57-1.14l.926-3.878L.822 6.74c-.709-.608-.331-1.77.6-1.844l3.973-.319zm.97 1.575L6.674 5.407a1.05 1.05 0 0 1-.886.644l-3.17.254 2.415 2.068c.3.256.43.658.339 1.041l-.738 3.093 2.713-1.658a1.05 1.05 0 0 1 1.095 0l2.713 1.658-.738-3.093a1.05 1.05 0 0 1 .339-1.041l2.414-2.068-3.169-.254a1.05 1.05 0 0 1-.886-.644z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 645 B |
1
apps/dotcom/client/assets/icons/icon/icon-star.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#000" fill-rule="evenodd" d="m7.184 1.893-.02.05-1.372 3.298a.3.3 0 0 1-.253.184l-3.561.286-.055.004-.331.027-.3.024a.3.3 0 0 0-.172.527l.23.196.252.216.041.036 2.713 2.324a.3.3 0 0 1 .097.297l-.83 3.475-.012.053-.077.324-.07.293a.3.3 0 0 0 .448.326l.258-.158.284-.173.046-.028 3.049-1.862a.3.3 0 0 1 .312 0l3.049 1.862.046.028.284.173.258.158a.3.3 0 0 0 .448-.326l-.07-.293-.077-.324-.013-.053-.829-3.475a.3.3 0 0 1 .097-.297l2.713-2.324.041-.036.253-.216.23-.196a.3.3 0 0 0-.172-.527l-.3-.024-.332-.027-.055-.004-3.56-.286a.3.3 0 0 1-.254-.184L8.247 1.943l-.021-.05-.128-.307-.116-.279a.3.3 0 0 0-.554 0l-.116.279zm.521 1.352-.99 2.38a1.3 1.3 0 0 1-1.096.797l-2.57.206 1.958 1.677a1.3 1.3 0 0 1 .418 1.29l-.598 2.507 2.2-1.344a1.3 1.3 0 0 1 1.356 0l2.2 1.344-.598-2.508a1.3 1.3 0 0 1 .418-1.289l1.958-1.677-2.57-.206a1.3 1.3 0 0 1-1.096-.796z" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 960 B |
1
apps/dotcom/client/assets/icons/icon/icon-tick.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#2E2E2E" d="M12 12v1.5H3V12zm0-9H3v10.5l-.077-.002a1.5 1.5 0 0 1-1.421-1.42L1.5 12V3a1.5 1.5 0 0 1 1.423-1.498L3 1.5h9A1.5 1.5 0 0 1 13.5 3v9l-.002.077A1.5 1.5 0 0 1 12 13.5z" style="fill:#2e2e2e;fill:color(display-p3 .1804 .1804 .1804);fill-opacity:1"/><path fill="#2E2E2E" d="M3.833 6.89a.75.75 0 0 1 .999.114l.048.06 2.307 3.23.042.065a.75.75 0 0 1-1.215.868l-.048-.06L3.66 7.935l-.042-.065a.75.75 0 0 1 .216-.981" style="fill:#2e2e2e;fill:color(display-p3 .1804 .1804 .1804);fill-opacity:1"/><path fill="#2E2E2E" d="M11.137 3.638a.75.75 0 0 0-.992.163l-.045.062-4.154 6.462a.75.75 0 0 0 1.262.811l4.154-6.461.038-.067a.75.75 0 0 0-.264-.97" style="fill:#2e2e2e;fill:color(display-p3 .1804 .1804 .1804);fill-opacity:1"/></svg>
|
||||
|
After Width: | Height: | Size: 816 B |
1
apps/dotcom/client/assets/icons/icon/icon-tldraw.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><g clip-path="url(#a)"><path fill="#000" fill-rule="evenodd" d="M.205 1.95A1.95 1.95 0 0 1 2.155 0h11.1a1.95 1.95 0 0 1 1.95 1.95v11.1a1.95 1.95 0 0 1-1.95 1.95h-11.1a1.95 1.95 0 0 1-1.95-1.95zm8.85 2.55a1.35 1.35 0 1 1-2.7 0 1.35 1.35 0 0 1 2.7 0m-1.793 7.65c.635.002 1.246-.88 1.475-1.372.281-.606.457-1.696.175-2.342-.198-.452-.652-.786-1.25-.786-.722 0-1.307.586-1.307 1.31 0 .2.046.387.127.553.075.154.174.314.274.476.315.508.637 1.03.244 1.398-.318.3-.218.761.262.763" clip-rule="evenodd"/></g><defs><clipPath id="a"><path fill="#fff" d="M.205 0h15v15h-15z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 664 B |
1
apps/dotcom/client/assets/icons/icon/icon-untick.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#2E2E2E" d="M12 12v1.5H3V12zm0-9H3v10.5l-.077-.002a1.5 1.5 0 0 1-1.421-1.42L1.5 12V3a1.5 1.5 0 0 1 1.423-1.498L3 1.5h9A1.5 1.5 0 0 1 13.5 3v9l-.002.077A1.5 1.5 0 0 1 12 13.5z" style="fill:#2e2e2e;fill:color(display-p3 .1804 .1804 .1804);fill-opacity:1"/></svg>
|
||||
|
After Width: | Height: | Size: 347 B |
1
apps/dotcom/client/assets/icons/icon/icon-update.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none"><path fill="#000" fill-rule="evenodd" d="M2.108 7.297c0 3.044 2.207 5.118 4.686 5.547a.521.521 0 1 1-.178 1.027c-2.911-.504-5.55-2.958-5.55-6.574 0-1.537.699-2.744 1.515-3.663.585-.658 1.254-1.193 1.793-1.602H2.737a.5.5 0 0 1 0-1h3a.5.5 0 0 1 .5.5v3a.5.5 0 0 1-1 0V2.687h-.001c-.572.432-1.27.958-1.875 1.64-.715.803-1.253 1.775-1.253 2.97m11.108.406c0-3.012-2.16-5.073-4.607-5.533A.521.521 0 0 1 8.8 1.146c2.874.54 5.457 2.98 5.457 6.557 0 1.537-.699 2.745-1.515 3.663-.585.658-1.254 1.193-1.792 1.602h1.636a.5.5 0 1 1 0 1h-3a.5.5 0 0 1-.5-.5v-3a.5.5 0 0 1 1 0v1.846l.002-.002c.571-.43 1.27-.957 1.875-1.638.714-.804 1.252-1.775 1.252-2.97" clip-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 743 B |
17
apps/dotcom/client/decs.d.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
declare namespace React {
|
||||
interface HTMLAttributes {
|
||||
/**
|
||||
* Indicates the browser should ignore the element and its contents in terms of interaction.
|
||||
* This is a boolean attribute but isn't properly supported by react yet - pass "" to enable
|
||||
* it, or undefined to disable it.
|
||||
*
|
||||
* https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/inert
|
||||
*/
|
||||
inert?: ''
|
||||
}
|
||||
}
|
||||
|
||||
declare module '*.svg' {
|
||||
const content: React.FunctionComponent<React.SVGAttributes<SVGElement>>
|
||||
export default content
|
||||
}
|
||||
32
apps/dotcom/client/e2e/README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Dotcom E2E conventions
|
||||
|
||||
Issue #9185 is moving dotcom tests toward user scenarios instead of isolated UI smoke tests. New collaborative dotcom coverage should prefer the scenario fixture in `fixtures/scenario-test.ts`.
|
||||
|
||||
## Scenario tests
|
||||
|
||||
- Name scenario files `*.scenario.spec.ts`. They run in the `chromium-scenarios` project with `fullyParallel: true`.
|
||||
- Run the ordinary local/CI dotcom suite with `yarn e2e-dotcom` from the repo root, or `yarn workspace dotcom e2e` from this workspace. This runs the scenario project only.
|
||||
- Run only the scenario suite without clearing stored auth with `yarn workspace dotcom e2e-scenarios`.
|
||||
- Run the preserved legacy smoke suite with `yarn e2e-dotcom-smoke` from the repo root, or `yarn workspace dotcom e2e-smoke` from this workspace. These specs live in `tests/smoke`, still use the reset-based `chromium` project, and are intentionally separate from the default runner. They do not run on CI; migrate coverage out of `tests/smoke` into scenario tests as the follow-ups below are unblocked.
|
||||
- Run both projects together with `yarn e2e-dotcom-all` from the repo root, or `yarn workspace dotcom e2e-all` from this workspace, when comparing old and new coverage.
|
||||
- Use named actors from the fixture: `owner`, `member`, and `visitor`.
|
||||
- Use `scenario.name('label')` for files and workspaces so test data is unique to the run.
|
||||
- Do not reset the whole database or shared users in scenario tests. Stable Clerk test accounts are reused by worker, while files and workspaces are namespaced by scenario.
|
||||
- Scenario actors use later Clerk test accounts than the legacy `chromium` project so explicit all-project runs can share a command while legacy tests still reset their own users.
|
||||
- Actor contexts and pages are owned by the fixture and closed automatically.
|
||||
- Use `scenario.createPersonalFile`, `scenario.createSharedFile`, `scenario.createGuestEditFile`, `scenario.createGuestViewFile`, `scenario.createPublishedFile`, `scenario.importFileFromUrl`, `scenario.downloadFileFromSidebar`, `scenario.setSharedLinkType`, `scenario.createWorkspaceWithMember`, and `scenario.createWorkspaceWithRemovedMember` for common setup.
|
||||
- Use `scenario.createLegacyRouteFixture` for legacy `/r`, `/ro`, `/v`, `/s`, and history route setup. It creates namespaced debug-only worker data instead of relying on shared production-like fixtures.
|
||||
- Use direct database setup only for preconditions that are expensive or impossible through the UI, such as enabling workspace flags. Keep the behavior under test in UI actions.
|
||||
- Prefer live assertions across already-open windows. Reload-based checks are still useful for persistence, but they should not be the only proof for collaboration, permission, or membership behavior.
|
||||
- Wait for readiness through actor helpers and page objects. `actor.goto` waits for the canvas, auth state, app store hydration when a signed-in app is present, mounted editor, file room connection, and visitor access metadata. Use the narrower `actor.waitForAuthLoaded`, `actor.waitForAppStoreHydrated`, `actor.waitForEditorReady`, `actor.waitForFileRoomConnected`, `actor.waitForVisitorAccessMetadata`, and `actor.waitForSessionClosed` helpers when a test needs to prove a specific transition. Avoid new sleeps unless the product intentionally waits.
|
||||
|
||||
## Auth follow-ups
|
||||
|
||||
The remaining auth edge cases are still tracked by issue #9185. Keep them out of the scenario project until they have Clerk-level mocking or stable account state that can run without per-test shared-user reset.
|
||||
|
||||
Follow-up migration groups:
|
||||
|
||||
- Auth legal acceptance and analytics consent: replace route-level Clerk response patching with a stable Clerk test account state or a mocked Clerk client boundary.
|
||||
- Auth verification and resend behavior: isolate Clerk network failures, malformed responses, cooldown behavior, and code input behavior without depending on shared signed-in user cleanup.
|
||||
- OAuth auth flows: add a Clerk/OAuth boundary that can exercise Google sign-in and legal acceptance without external redirects.
|
||||
- Auth response edge cases: cover completed sessions, inconsistent `missing_fields` payloads, and missing email-code factor data once Clerk responses can be mocked below the route layer.
|
||||
7
apps/dotcom/client/e2e/consts.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const NUMBER_OF_USERS = 8
|
||||
export const USERS = Array.from({ length: NUMBER_OF_USERS }).map(
|
||||
(_, i) => `huppy+clerk_test${i + 1}@tldraw.com`
|
||||
)
|
||||
export const OTHER_USERS = Array.from({ length: NUMBER_OF_USERS }).map(
|
||||
(_, i) => `suppy+clerk_test${i + 1}@tldraw.com`
|
||||
)
|
||||
68
apps/dotcom/client/e2e/fixtures/Database.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import fs from 'fs'
|
||||
import { Page } from '@playwright/test'
|
||||
import { DB } from '@tldraw/dotcom-shared'
|
||||
import { Kysely, PostgresDialect, sql } from 'kysely'
|
||||
import pg from 'pg'
|
||||
import { OTHER_USERS, USERS } from '../consts'
|
||||
import { getStorageStateFileName } from './helpers'
|
||||
|
||||
const db = new Kysely<DB>({
|
||||
dialect: new PostgresDialect({
|
||||
pool: new pg.Pool({
|
||||
connectionString: 'postgresql://user:password@127.0.0.1:6432/postgres',
|
||||
application_name: 'migrate',
|
||||
idleTimeoutMillis: 10_000,
|
||||
max: 10,
|
||||
}),
|
||||
}),
|
||||
log: ['error'],
|
||||
})
|
||||
|
||||
export class Database {
|
||||
constructor(
|
||||
readonly page: Page | null,
|
||||
private parallelIndex: number
|
||||
) {}
|
||||
|
||||
async reset() {
|
||||
await this.cleanUpUser(true)
|
||||
await this.cleanUpUser(false)
|
||||
}
|
||||
|
||||
getEmail(isOther: boolean = false) {
|
||||
return getTestUserEmail(this.parallelIndex, isOther ? 'suppy' : 'huppy')
|
||||
}
|
||||
|
||||
async getUserId(isOther: boolean = false) {
|
||||
return await this.getUserIdByEmail(this.getEmail(isOther))
|
||||
}
|
||||
|
||||
async getUserIdByEmail(email: string) {
|
||||
const dbUser = await sql<{
|
||||
id: string
|
||||
}>`SELECT id FROM public.user WHERE email = ${email ?? ''}`.execute(db)
|
||||
if (!dbUser.rows[0]) return
|
||||
return dbUser.rows[0].id
|
||||
}
|
||||
|
||||
private async cleanUpUser(isOther: boolean) {
|
||||
const fileName = getStorageStateFileName(this.parallelIndex, isOther ? 'suppy' : 'huppy')
|
||||
if (!fs.existsSync(fileName)) return
|
||||
const id = await this.getUserId(isOther)
|
||||
if (!id) return
|
||||
try {
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
await fetch(`http://localhost:3000/api/app/__test__/user/${id}/prepare-for-test`, {
|
||||
method: 'POST',
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Error', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type TestUser = 'huppy' | 'suppy'
|
||||
|
||||
export function getTestUserEmail(index: number, user: TestUser) {
|
||||
return user === 'suppy' ? OTHER_USERS[index] : USERS[index]
|
||||
}
|
||||
34
apps/dotcom/client/e2e/fixtures/DeleteFileDialog.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class DeleteFileDialog {
|
||||
public readonly dialog: Locator
|
||||
public readonly confirmButton: Locator
|
||||
public readonly cancelButton: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.dialog = this.page.getByRole('dialog', { name: 'Delete file' })
|
||||
this.confirmButton = this.dialog.getByRole('button', { name: 'Delete' })
|
||||
this.cancelButton = this.dialog.getByRole('button', { name: 'Cancel' })
|
||||
}
|
||||
|
||||
async isVisible() {
|
||||
return this.dialog.isVisible()
|
||||
}
|
||||
|
||||
async expectIsVisible() {
|
||||
await expect(this.dialog).toBeVisible()
|
||||
}
|
||||
|
||||
async expectIsNotVisible() {
|
||||
await expect(this.dialog).not.toBeVisible()
|
||||
}
|
||||
|
||||
async confirmDeletion() {
|
||||
await this.confirmButton.click()
|
||||
}
|
||||
|
||||
async cancelDeletion() {
|
||||
await this.cancelButton.click()
|
||||
}
|
||||
}
|
||||
95
apps/dotcom/client/e2e/fixtures/Editor.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { sleep } from './helpers'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { step } from './tla-test'
|
||||
|
||||
export class Editor {
|
||||
public readonly sidebarToggle: Locator
|
||||
private readonly fileName: Locator
|
||||
private readonly shapes: Locator
|
||||
private readonly pageMenu: Locator
|
||||
|
||||
constructor(
|
||||
public readonly page: Page,
|
||||
private readonly sidebar: Sidebar
|
||||
) {
|
||||
this.sidebarToggle = this.page.getByTestId('tla-sidebar-toggle')
|
||||
this.fileName = this.page.getByTestId('tla-file-name')
|
||||
this.shapes = this.page.locator('.tl-shape')
|
||||
this.pageMenu = this.page.getByTestId('tla-main-menu')
|
||||
}
|
||||
|
||||
async toggleSidebar() {
|
||||
await this.sidebarToggle.click()
|
||||
await sleep(500)
|
||||
}
|
||||
|
||||
@step
|
||||
async ensureSidebarOpen() {
|
||||
const visible = await this.sidebar.isVisible()
|
||||
if (!visible) {
|
||||
await this.toggleSidebar()
|
||||
}
|
||||
await this.sidebar.expectIsVisible()
|
||||
}
|
||||
|
||||
@step
|
||||
async ensureSidebarClosed() {
|
||||
const visible = await this.sidebar.isVisible()
|
||||
if (visible) {
|
||||
await this.toggleSidebar()
|
||||
}
|
||||
await this.sidebar.expectIsNotVisible()
|
||||
}
|
||||
|
||||
async isLoaded() {
|
||||
await expect(this.sidebarToggle).toBeVisible()
|
||||
}
|
||||
|
||||
async getShapeCount() {
|
||||
return await this.shapes.count()
|
||||
}
|
||||
|
||||
@step
|
||||
async expectShapesCount(expected: number, timeout?: number) {
|
||||
await expect(this.shapes).toHaveCount(expected, { timeout })
|
||||
}
|
||||
|
||||
async getCurrentFileName() {
|
||||
const text = await this.fileName.innerText()
|
||||
// The UI replaces spaces with \u00a0 to prevent line-breaking; normalise back.
|
||||
return text.replace(/\u00a0/g, ' ')
|
||||
}
|
||||
|
||||
@step
|
||||
async rename(newName: string) {
|
||||
await this.fileName.click()
|
||||
await this.page.getByTestId('tla-file-name-input').fill(newName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
}
|
||||
|
||||
@step
|
||||
async openPageMenu() {
|
||||
await this.pageMenu.click()
|
||||
}
|
||||
|
||||
@step
|
||||
async createNewPage() {
|
||||
await this.page.getByTestId('page-menu.button').click()
|
||||
await expect(this.page.getByTestId('page-menu.create')).toBeVisible()
|
||||
await expect(this.page.getByTestId('page-menu.item').first()).toBeVisible()
|
||||
const count = await this.page.getByTestId('page-menu.item').count()
|
||||
await this.page.getByTestId('page-menu.create').click()
|
||||
await expect(this.page.getByTestId('page-menu.item')).toHaveCount(count + 1)
|
||||
await this.page.keyboard.press('Enter')
|
||||
await this.page.keyboard.press('Escape')
|
||||
}
|
||||
|
||||
@step
|
||||
async createTextShape(text: string) {
|
||||
await this.page.getByTestId('tools.select').click()
|
||||
await this.page.locator('.tl-background').click({ clickCount: 2 })
|
||||
await this.page.locator('div[contenteditable="true"]').fill(text)
|
||||
}
|
||||
}
|
||||
30
apps/dotcom/client/e2e/fixtures/ErrorPages.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Locator, Page } from '@playwright/test'
|
||||
import { expect } from '../fixtures/tla-test'
|
||||
|
||||
export class ErrorPage {
|
||||
public readonly notFound: Locator
|
||||
public readonly pageNotFound: Locator
|
||||
public readonly privateFile: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.notFound = this.page.getByRole('heading', { name: 'Not found' })
|
||||
this.pageNotFound = this.page.getByRole('heading', { name: 'Page not found' })
|
||||
this.privateFile = this.page.getByRole('heading', { name: 'Private file' })
|
||||
}
|
||||
|
||||
async expectNotFoundVisible() {
|
||||
await expect(this.notFound).toBeVisible()
|
||||
}
|
||||
|
||||
async expectNotFoundNotVisible() {
|
||||
await expect(this.notFound).not.toBeVisible()
|
||||
}
|
||||
|
||||
async expectPageNotFoundVisible() {
|
||||
await expect(this.pageNotFound).toBeVisible()
|
||||
}
|
||||
|
||||
async expectPrivateFileVisible() {
|
||||
await expect(this.privateFile).toBeVisible()
|
||||
}
|
||||
}
|
||||
103
apps/dotcom/client/e2e/fixtures/HomePage.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { Editor } from './Editor'
|
||||
import { step } from './tla-test'
|
||||
|
||||
const rootUrl = 'http://localhost:3000/'
|
||||
|
||||
export class HomePage {
|
||||
public readonly signInButton: Locator
|
||||
public readonly tldrawEditor: Locator
|
||||
public readonly tldrawCanvas: Locator
|
||||
constructor(
|
||||
private readonly page: Page,
|
||||
private readonly editor: Editor
|
||||
) {
|
||||
this.signInButton = this.page.getByTestId('tla-sign-in-button')
|
||||
this.tldrawEditor = this.page.getByTestId('tla-editor')
|
||||
this.tldrawCanvas = this.page.getByTestId('canvas')
|
||||
}
|
||||
|
||||
@step
|
||||
async loginAs(email: string) {
|
||||
if (this.page.url() !== rootUrl) {
|
||||
await this.goto()
|
||||
}
|
||||
await this.page.waitForLoadState('domcontentloaded')
|
||||
const isSideBarToggleVisible =
|
||||
(await this.editor.sidebarToggle.isVisible().catch(() => false)) ?? false
|
||||
if (isSideBarToggleVisible) return
|
||||
|
||||
await expect(this.signInButton).toBeVisible()
|
||||
|
||||
await this.signInButton.click({ force: true })
|
||||
await this.page.getByTestId('tla-identifier-input').fill(email)
|
||||
await this.page.getByTestId('tla-continue-with-email-button').click()
|
||||
// will go to
|
||||
await this.page.getByTestId('tla-verification-code-input').fill('424242')
|
||||
// Wait till we're on a file page, e.g. /f/:someId
|
||||
await this.page.waitForURL(new RegExp(`${rootUrl}f/.*`))
|
||||
await expect(async () => {
|
||||
await expect(this.page.getByTestId('tla-sidebar-toggle')).toBeVisible()
|
||||
}).toPass()
|
||||
}
|
||||
|
||||
async loginWithEmailAndPassword(email: string, password: string) {
|
||||
const isSideBarToggleVisible = await this.editor.sidebarToggle.isVisible()
|
||||
// We are already signed in
|
||||
if (isSideBarToggleVisible) return
|
||||
await expect(this.signInButton).toBeVisible()
|
||||
await this.signInButton.click()
|
||||
await this.page.getByLabel('Email address').fill(email)
|
||||
// Note: This method is for staging/production Clerk flows that use passwords
|
||||
// The password field appears after clicking continue on email
|
||||
await this.page.getByTestId('tla-continue-with-email-button').click()
|
||||
await this.page.waitForTimeout(500)
|
||||
await this.page.getByRole('textbox', { name: 'Password' }).fill(password)
|
||||
// After entering password, look for any Continue button (Clerk's default buttons don't have test IDs)
|
||||
await this.page.getByRole('button', { name: 'Continue', exact: true }).click()
|
||||
await this.page.waitForTimeout(1000)
|
||||
await this.handleTermsIfNeeded()
|
||||
await expect(async () => {
|
||||
await expect(this.page.getByTestId('tla-sidebar-toggle')).toBeVisible()
|
||||
}).toPass()
|
||||
}
|
||||
|
||||
private async handleTermsIfNeeded() {
|
||||
const acceptAndContinueButton = this.page.getByTestId('tla-accept-and-continue-button')
|
||||
// Wait a bit for the dialog to potentially appear
|
||||
await this.page.waitForTimeout(500)
|
||||
if ((await acceptAndContinueButton.count()) === 0) return
|
||||
await expect(acceptAndContinueButton).toBeVisible()
|
||||
await expect(acceptAndContinueButton).toBeEnabled()
|
||||
await acceptAndContinueButton.click()
|
||||
// Wait for the button click to complete
|
||||
await this.page.waitForTimeout(500)
|
||||
}
|
||||
|
||||
async expectSignInButtonVisible() {
|
||||
await expect(async () => {
|
||||
await expect(this.signInButton).toBeVisible()
|
||||
}).toPass()
|
||||
}
|
||||
|
||||
async expectSignInButtonNotVisible() {
|
||||
await expect(async () => {
|
||||
await expect(this.signInButton).not.toBeVisible()
|
||||
}).toPass()
|
||||
}
|
||||
|
||||
async goto(url = rootUrl) {
|
||||
await this.page.goto(url, { waitUntil: 'load' })
|
||||
}
|
||||
|
||||
async isLoaded() {
|
||||
// Swat away the terms dialog if it appears.
|
||||
await this.handleTermsIfNeeded()
|
||||
|
||||
await expect(async () => {
|
||||
await expect(this.tldrawEditor).toBeVisible({ timeout: 10000 })
|
||||
await expect(this.tldrawCanvas).toBeVisible({ timeout: 10000 })
|
||||
}).toPass()
|
||||
}
|
||||
}
|
||||
51
apps/dotcom/client/e2e/fixtures/ImportHelper.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { Page } from '@playwright/test'
|
||||
import { step } from './tla-test'
|
||||
|
||||
export const IMPORT_URL = 'https://e2e-mock.tldraw.xyz/test-import.tldr'
|
||||
|
||||
// Sequences match the current record versions so no migrations run on import,
|
||||
// which would otherwise overwrite fields (e.g. AddName sets name='').
|
||||
const MINIMAL_TLDR_FILE = {
|
||||
tldrawFileFormatVersion: 1,
|
||||
schema: {
|
||||
schemaVersion: 2,
|
||||
sequences: { 'com.tldraw.document': 2, 'com.tldraw.page': 1 } as Record<string, number>,
|
||||
},
|
||||
records: [
|
||||
{
|
||||
typeName: 'document',
|
||||
id: 'document:document',
|
||||
name: 'e2e import test',
|
||||
gridSize: 10,
|
||||
meta: {},
|
||||
},
|
||||
{
|
||||
typeName: 'page',
|
||||
id: 'page:page',
|
||||
index: 'a1',
|
||||
meta: {},
|
||||
name: 'Page 1',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
export class ImportHelper {
|
||||
constructor(private readonly page: Page) {}
|
||||
|
||||
@step
|
||||
async mockUrl(url = IMPORT_URL) {
|
||||
await this.page.route(url, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
headers: { 'Access-Control-Allow-Origin': '*' },
|
||||
body: JSON.stringify(MINIMAL_TLDR_FILE),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
@step
|
||||
async navigate(url = IMPORT_URL) {
|
||||
await this.page.goto(`http://localhost:3000/import?url=${encodeURIComponent(url)}`)
|
||||
}
|
||||
}
|
||||
169
apps/dotcom/client/e2e/fixtures/ShareMenu.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { expect, step } from './tla-test'
|
||||
|
||||
type ShareMenuTab = 'invite' | 'export' | 'publish'
|
||||
|
||||
export class ShareMenu {
|
||||
public readonly shareButton: Locator
|
||||
|
||||
public readonly exportTabButton: Locator
|
||||
public readonly inviteTabButton: Locator
|
||||
public readonly publishTabButton: Locator
|
||||
public readonly anonShareTabButton: Locator
|
||||
|
||||
public readonly exportTabPage: Locator
|
||||
public readonly inviteTabPage: Locator
|
||||
public readonly publishTabPage: Locator
|
||||
public readonly anonShareTabPage: Locator
|
||||
|
||||
public readonly publishChangesButton: Locator
|
||||
|
||||
public readonly copyLinkButton: Locator
|
||||
public readonly exportImageButton: Locator
|
||||
public readonly tabs: { anonShare: Locator; invite: Locator; export: Locator; publish: Locator }
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.shareButton = this.page.getByTestId('tla-share-button')
|
||||
this.exportTabButton = this.page.getByTestId('tla-share-tab-button-export')
|
||||
this.inviteTabButton = this.page.getByTestId('tla-share-tab-button-share')
|
||||
this.publishTabButton = this.page.getByTestId('tla-share-tab-button-publish')
|
||||
this.anonShareTabButton = this.page.getByTestId('tla-share-tab-button-anon-share')
|
||||
|
||||
this.exportTabPage = this.page.getByTestId('tla-share-tab-page-export')
|
||||
this.inviteTabPage = this.page.getByTestId('tla-share-tab-page-share')
|
||||
this.publishTabPage = this.page.getByTestId('tla-share-tab-page-publish')
|
||||
this.anonShareTabPage = this.page.getByTestId('tla-share-tab-page-anon-share')
|
||||
|
||||
this.publishChangesButton = this.page.getByRole('button', {
|
||||
name: 'Publish changes',
|
||||
exact: true,
|
||||
})
|
||||
this.copyLinkButton = this.page.getByRole('button', { name: 'Copy link' })
|
||||
this.exportImageButton = this.page.getByRole('button', { name: 'Export image' })
|
||||
this.tabs = {
|
||||
anonShare: this.anonShareTabButton,
|
||||
invite: this.inviteTabButton,
|
||||
export: this.exportTabButton,
|
||||
publish: this.publishTabButton,
|
||||
}
|
||||
this.shareFile = this.shareFile.bind(this)
|
||||
this.unshareFile = this.unshareFile.bind(this)
|
||||
this.publishFile = this.publishFile.bind(this)
|
||||
this.unpublishFile = this.unpublishFile.bind(this)
|
||||
}
|
||||
|
||||
async open() {
|
||||
await this.shareButton.click()
|
||||
}
|
||||
|
||||
async isInviteButtonVisible() {
|
||||
return await this.inviteTabButton.isVisible()
|
||||
}
|
||||
|
||||
async share(tab: ShareMenuTab) {
|
||||
await this.ensureTabSelected(tab)
|
||||
if (!(await this.isToggleChecked())) {
|
||||
await this.page.getByRole('switch').check()
|
||||
}
|
||||
}
|
||||
|
||||
async unshare(tab: ShareMenuTab) {
|
||||
await this.ensureTabSelected(tab)
|
||||
if (await this.isToggleChecked()) {
|
||||
await this.page.getByRole('switch').uncheck()
|
||||
}
|
||||
}
|
||||
|
||||
@step
|
||||
async shareFile() {
|
||||
await this.share('invite')
|
||||
}
|
||||
|
||||
@step
|
||||
async unshareFile() {
|
||||
await this.unshare('invite')
|
||||
}
|
||||
|
||||
@step
|
||||
async publishFile() {
|
||||
await this.share('publish')
|
||||
}
|
||||
|
||||
@step
|
||||
async unpublishFile() {
|
||||
await this.unshare('publish')
|
||||
}
|
||||
|
||||
async isToggleChecked() {
|
||||
return await this.page.getByRole('switch').isChecked()
|
||||
}
|
||||
|
||||
@step
|
||||
async copyLink() {
|
||||
const sentinel = `__copy_link_pending_${Date.now()}__`
|
||||
await this.page.evaluate(async (value) => {
|
||||
await navigator.clipboard.writeText(value)
|
||||
}, sentinel)
|
||||
|
||||
await this.copyLinkButton.click()
|
||||
|
||||
let url = ''
|
||||
await expect(async () => {
|
||||
url = await this.page.evaluate(async () => await navigator.clipboard.readText())
|
||||
expect(url).not.toBe(sentinel)
|
||||
expect(url).toBeTruthy()
|
||||
const parsed = new URL(url)
|
||||
expect(parsed.origin).toBe('http://localhost:3000')
|
||||
// Share menu links should point to file/publish routes, not group invite routes.
|
||||
expect(parsed.pathname.startsWith('/f/') || parsed.pathname.startsWith('/p/')).toBe(true)
|
||||
}).toPass({ timeout: 5000 })
|
||||
return url
|
||||
}
|
||||
|
||||
@step
|
||||
async openShareMenuAndCopyInviteLink() {
|
||||
await this.open()
|
||||
await this.inviteTabButton.click()
|
||||
await this.ensureTabSelected('invite')
|
||||
return await this.copyLink()
|
||||
}
|
||||
|
||||
@step
|
||||
async openShareMenuAndCopyPublishedLink() {
|
||||
await this.open()
|
||||
await this.publishTabButton.click()
|
||||
await this.ensureTabSelected('publish')
|
||||
return await this.copyLink()
|
||||
}
|
||||
|
||||
@step
|
||||
async exportFile() {
|
||||
await this.ensureTabSelected('export')
|
||||
await this.exportImageButton.click()
|
||||
}
|
||||
|
||||
@step
|
||||
async isTabSelected(tab: ShareMenuTab) {
|
||||
const attr = await this.tabs[tab].getAttribute('data-active')
|
||||
return attr === 'true'
|
||||
}
|
||||
|
||||
@step
|
||||
async ensureTabSelected(tab: ShareMenuTab) {
|
||||
if (await this.isTabSelected(tab)) return
|
||||
const locator = this.tabs[tab]
|
||||
await locator.click()
|
||||
}
|
||||
|
||||
@step
|
||||
async publishChanges() {
|
||||
await this.ensureTabSelected('publish')
|
||||
await this.publishChangesButton.click()
|
||||
}
|
||||
|
||||
async getShareType() {
|
||||
return (await this.page.waitForSelector('[data-testid="shared-link-type-select"]'))
|
||||
.textContent()
|
||||
.then((text) => text?.trim())
|
||||
}
|
||||
}
|
||||
649
apps/dotcom/client/e2e/fixtures/Sidebar.ts
Normal file
@@ -0,0 +1,649 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { MAX_WORKSPACE_NAME_LENGTH } from '@tldraw/dotcom-shared'
|
||||
import { expect, step } from './tla-test'
|
||||
|
||||
// The createWorkspace/updateWorkspace mutators clamp names to MAX_WORKSPACE_NAME_LENGTH, so the
|
||||
// stored (and therefore displayed and synced) name diverges from a longer requested name. Scenario
|
||||
// ids alone run close to that limit, so workspace helpers must match against the clamped form or
|
||||
// they search for a name the app never renders.
|
||||
function clampWorkspaceName(name: string) {
|
||||
return name.trim().slice(0, MAX_WORKSPACE_NAME_LENGTH)
|
||||
}
|
||||
|
||||
// Cross-client Zero sync (accepting an invite, being removed, a workspace being deleted) delivers
|
||||
// workspace-membership rows asynchronously and with variable latency on a busy CI machine. This is
|
||||
// the budget for the data-layer wait that actually gates on that sync; it is intentionally generous
|
||||
// while still bounded, so a genuine sync regression fails with a clear signal rather than hanging.
|
||||
const WORKSPACE_MEMBERSHIP_SYNC_TIMEOUT = 30_000
|
||||
|
||||
export class Sidebar {
|
||||
public readonly fileLink = '[data-element="file-link"]'
|
||||
|
||||
public readonly sidebarLayout: Locator
|
||||
public readonly sidebar: Locator
|
||||
public readonly sidebarLogo: Locator
|
||||
public readonly createFileButton: Locator
|
||||
public readonly userSettingsMenu: Locator
|
||||
public readonly helpMenu: Locator
|
||||
public readonly themeButton: Locator
|
||||
public readonly darkModeButton: Locator
|
||||
public readonly signOutButton: Locator
|
||||
constructor(public readonly page: Page) {
|
||||
this.sidebarLayout = this.page.getByTestId('tla-sidebar-layout')
|
||||
this.sidebar = this.page.getByTestId('tla-sidebar')
|
||||
this.sidebarLogo = this.page.getByTestId('tla-sidebar-logo-icon')
|
||||
this.createFileButton = this.page.getByTestId('tla-create-file')
|
||||
this.userSettingsMenu = this.page.getByTestId('tla-sidebar-user-settings-trigger')
|
||||
this.helpMenu = this.userSettingsMenu
|
||||
this.themeButton = this.page.getByTestId('dialog-sub.theme-button')
|
||||
this.darkModeButton = this.page.getByRole('menuitemcheckbox', { name: 'Dark', exact: true })
|
||||
this.signOutButton = this.page.getByTestId('dialog.sign-out')
|
||||
}
|
||||
|
||||
async isVisible() {
|
||||
return this.sidebarLogo.isVisible()
|
||||
}
|
||||
|
||||
async expectIsVisible() {
|
||||
await expect(this.sidebarLogo).toBeInViewport()
|
||||
}
|
||||
|
||||
async expectIsNotVisible() {
|
||||
await expect(this.sidebarLogo).not.toBeInViewport()
|
||||
}
|
||||
|
||||
async createNewDocument(name?: string) {
|
||||
const numDocuments = await this.getNumberOfFiles()
|
||||
const previousUrl = this.page.url()
|
||||
await this.createFileButton.click()
|
||||
const input = this.page.getByTestId('tla-sidebar-rename-input')
|
||||
await expect(input).toBeVisible()
|
||||
await expect(input).toBeFocused()
|
||||
if (name) {
|
||||
await input.fill(name)
|
||||
}
|
||||
await Promise.all([
|
||||
this.page.waitForURL(
|
||||
(url) => url.toString() !== previousUrl && url.pathname.startsWith('/f/'),
|
||||
{ timeout: 10000 }
|
||||
),
|
||||
this.page.keyboard.press('Enter'),
|
||||
])
|
||||
await expect.poll(() => this.getNumberOfFiles()).toBe(numDocuments + 1)
|
||||
// the create button has a 1000ms throttle - wait so the next creation isn't swallowed
|
||||
await this.page.waitForTimeout(1100)
|
||||
}
|
||||
|
||||
async getNumberOfFiles() {
|
||||
return (await this.page.$$(this.fileLink)).length
|
||||
}
|
||||
|
||||
async openUserSettingsMenu() {
|
||||
await this.userSettingsMenu.hover()
|
||||
await this.userSettingsMenu.click()
|
||||
// Wait for the dropdown content to mount before child-item clicks race the open animation.
|
||||
await expect(this.page.getByRole('menu')).toBeVisible()
|
||||
}
|
||||
|
||||
@step
|
||||
async setDarkMode() {
|
||||
await this.openUserSettingsMenu()
|
||||
await this.themeButton.hover()
|
||||
await this.darkModeButton.click()
|
||||
}
|
||||
|
||||
@step
|
||||
async openLanguageMenu(languageButtonText: string) {
|
||||
// need the editor to be mounted for the menu to be available
|
||||
await expect(this.page.getByTestId('canvas')).toBeVisible()
|
||||
await this.openUserSettingsMenu()
|
||||
await this.page.getByText(languageButtonText).hover()
|
||||
}
|
||||
|
||||
@step
|
||||
async expectLanguageToBe(languageButtonText: string, language: string) {
|
||||
await this.openLanguageMenu(languageButtonText)
|
||||
await expect(async () => {
|
||||
const checkedAttribute = await this.page
|
||||
.getByRole('menuitemcheckbox', { name: language })
|
||||
.getAttribute('data-state')
|
||||
expect(checkedAttribute).toBe('checked')
|
||||
}).toPass()
|
||||
}
|
||||
|
||||
@step
|
||||
async setLanguage(languageButtonText: string, language: string) {
|
||||
await this.openLanguageMenu(languageButtonText)
|
||||
await this.page.getByRole('menuitemcheckbox', { name: language }).click()
|
||||
await this.page.keyboard.press('Escape')
|
||||
}
|
||||
|
||||
@step
|
||||
async signOut() {
|
||||
await this.openUserSettingsMenu()
|
||||
await this.signOutButton.click()
|
||||
}
|
||||
|
||||
async expectToContainText(text: string) {
|
||||
await expect(this.sidebarLayout).toContainText(text)
|
||||
}
|
||||
|
||||
async expectNotToContainText(text: string) {
|
||||
await expect(this.sidebarLayout).not.toContainText(text)
|
||||
}
|
||||
|
||||
private getTestId(section: string, index: number, suffix?: string) {
|
||||
return `tla-file-link-${section}-${index}${suffix ? `-${suffix}` : ''}`
|
||||
}
|
||||
|
||||
getFirstFileLink() {
|
||||
return this.getFileLink('today', 0)
|
||||
}
|
||||
|
||||
getFileLink(section: string, index: number) {
|
||||
return this.page.getByTestId(this.getTestId(section, index))
|
||||
}
|
||||
|
||||
async getFirstFileName() {
|
||||
return await this.getFileName(0)
|
||||
}
|
||||
|
||||
async getFileName(index: number) {
|
||||
return await this.page
|
||||
.getByTestId(this.getTestId('today', index, 'name'))
|
||||
.innerText({ timeout: 5000 })
|
||||
}
|
||||
|
||||
@step
|
||||
private async openFileMenu(fileLink: Locator) {
|
||||
await fileLink?.hover()
|
||||
const button = fileLink.getByRole('button')
|
||||
await button?.click()
|
||||
}
|
||||
|
||||
@step
|
||||
async deleteFile(index: number) {
|
||||
await this.openFileMenu(this.getFileLink('today', index))
|
||||
await this.deleteFromFileMenu()
|
||||
}
|
||||
|
||||
@step
|
||||
private async deleteFromFileMenu() {
|
||||
await this.page.getByRole('menuitem', { name: 'Delete' }).click()
|
||||
}
|
||||
|
||||
@step
|
||||
async renameFile(index: number, newName: string) {
|
||||
const fileLink = this.getFileLink('today', index)
|
||||
await this.openFileMenu(fileLink)
|
||||
await this.renameFromFileMenu(newName)
|
||||
}
|
||||
|
||||
@step
|
||||
async renameFileByName(fileName: string, newName: string) {
|
||||
await this.openFileMenuByName(fileName)
|
||||
await this.renameFromFileMenu(newName)
|
||||
}
|
||||
|
||||
@step
|
||||
async renameFromFileMenu(name: string) {
|
||||
await this.page.getByRole('menuitem', { name: 'Rename' }).click()
|
||||
const input = this.page.getByTestId('tla-sidebar-rename-input')
|
||||
await input.fill(name)
|
||||
await this.page.keyboard.press('Enter')
|
||||
}
|
||||
|
||||
@step
|
||||
private async duplicateFromFileMenu(name?: string) {
|
||||
await this.page.getByRole('menuitem', { name: 'Duplicate' }).click()
|
||||
const input = this.page.getByTestId('tla-sidebar-rename-input')
|
||||
await expect(input).toBeVisible()
|
||||
await expect(input).toBeFocused()
|
||||
if (name) {
|
||||
await input.fill(name)
|
||||
}
|
||||
await this.page.keyboard.press('Enter')
|
||||
}
|
||||
|
||||
@step
|
||||
async pinFromFileMenu(index: number) {
|
||||
const fileLink = this.getFileLink('today', index)
|
||||
await this.openFileMenu(fileLink)
|
||||
await this.page.getByRole('menuitem', { name: 'Pin' }).click()
|
||||
}
|
||||
|
||||
@step
|
||||
async unpinFromFileMenu(index: number) {
|
||||
const fileLink = this.getFileLink('pinned', index)
|
||||
await this.openFileMenu(fileLink)
|
||||
await this.page.getByRole('menuitem', { name: 'Unpin' }).click()
|
||||
}
|
||||
|
||||
@step
|
||||
async duplicateFile(index: number, name?: string) {
|
||||
const fileLink = this.getFileLink('today', index)
|
||||
await this.openFileMenu(fileLink)
|
||||
await this.duplicateFromFileMenu(name)
|
||||
}
|
||||
|
||||
@step
|
||||
async copyFileLinkFromFileMenu() {
|
||||
await this.page.getByRole('menuitem', { name: 'Copy link' }).click()
|
||||
}
|
||||
|
||||
// The app writes to the clipboard asynchronously after the copy action, so reading it once can
|
||||
// return a stale or empty value. Poll until the clipboard holds a valid URL (optionally matching
|
||||
// an expected path) to avoid races.
|
||||
private async readClipboardUrl(pathPattern?: RegExp): Promise<string> {
|
||||
let value = ''
|
||||
await expect(async () => {
|
||||
value = await this.page.evaluate(() => navigator.clipboard.readText())
|
||||
const url = new URL(value)
|
||||
if (pathPattern) expect(url.pathname).toMatch(pathPattern)
|
||||
}).toPass({ timeout: 10000 })
|
||||
return value
|
||||
}
|
||||
|
||||
@step
|
||||
async copyFileLink(index: number) {
|
||||
const fileLink = this.getFileLink('today', index)
|
||||
await this.openFileMenu(fileLink)
|
||||
await this.copyFileLinkFromFileMenu()
|
||||
return await this.readClipboardUrl(/^\/f\//)
|
||||
}
|
||||
|
||||
@step
|
||||
async copyFileLinkByName(name: string): Promise<string> {
|
||||
const fileLink = this.getFileByName(name)
|
||||
await this.openFileMenu(fileLink)
|
||||
await this.copyFileLinkFromFileMenu()
|
||||
return await this.readClipboardUrl(/^\/f\//)
|
||||
}
|
||||
|
||||
async getAfterElementStyle(element: Locator, property: string): Promise<string> {
|
||||
return element.evaluate((el, property) => {
|
||||
return window.getComputedStyle(el, '::after').getPropertyValue(property)
|
||||
}, property)
|
||||
}
|
||||
|
||||
async isHinted(fileLink: Locator): Promise<boolean> {
|
||||
const backgroundColor = await this.getAfterElementStyle(fileLink, 'background-color')
|
||||
return backgroundColor === 'rgba(9, 11, 12, 0.043)'
|
||||
}
|
||||
|
||||
@step
|
||||
async closeAccountMenu() {
|
||||
await this.page.keyboard.press('Escape')
|
||||
}
|
||||
|
||||
// Workspace-related methods
|
||||
|
||||
// A single attempt at opening the switcher: click only when it isn't already open, then confirm
|
||||
// the menu content mounted (Home is always present). Callers that also act on a menu item should
|
||||
// run this *inside* their own retry loop alongside that action — re-renders and navigation that
|
||||
// fire while the app settles after a cross-client change can dismiss the menu between opening it
|
||||
// and acting, so the open and the action have to be retried together, not in sequence.
|
||||
private async ensureWorkspaceSwitcherOpen() {
|
||||
const home = this.page.getByTestId('tla-workspace-switcher-home')
|
||||
if (!(await home.isVisible())) {
|
||||
await this.page.getByTestId('tla-workspace-switcher').click()
|
||||
}
|
||||
await expect(home).toBeVisible({ timeout: 2000 })
|
||||
}
|
||||
|
||||
@step
|
||||
async openWorkspaceSwitcher() {
|
||||
await expect(() => this.ensureWorkspaceSwitcherOpen()).toPass({ timeout: 15000 })
|
||||
}
|
||||
|
||||
@step
|
||||
async createWorkspace(name: string) {
|
||||
name = clampWorkspaceName(name)
|
||||
// Creating a workspace happens from the workspace switcher dropdown.
|
||||
await this.openWorkspaceSwitcher()
|
||||
await this.page.getByTestId('tla-create-workspace-menu-item').click()
|
||||
|
||||
const input = this.page.getByPlaceholder('Workspace name')
|
||||
await expect(input).toBeVisible()
|
||||
await input.fill(name)
|
||||
|
||||
await this.page.getByRole('button', { name: 'Create workspace' }).click()
|
||||
|
||||
// Creating a workspace switches to it and opens its seeded welcome file. That file
|
||||
// arrives named, so (unlike a blank file) there is no inline rename to dismiss.
|
||||
await expect(async () => {
|
||||
const activeName = await this.page.getByTestId('tla-active-workspace-name').innerText()
|
||||
if (activeName !== name) {
|
||||
await this.switchToWorkspace(name)
|
||||
}
|
||||
await this.expectActiveWorkspace(name)
|
||||
}).toPass({ timeout: 20000 })
|
||||
}
|
||||
|
||||
getWorkspaceLink(name: string) {
|
||||
return this.page
|
||||
.locator('[data-element="workspace-link"]')
|
||||
.filter({ hasText: clampWorkspaceName(name) })
|
||||
}
|
||||
|
||||
async getHomeWorkspaceName() {
|
||||
const name = await this.page.evaluate(() => {
|
||||
const app = (window as any).app
|
||||
const homeWorkspaceId = app?.getHomeWorkspaceId?.()
|
||||
return app?.getWorkspaceMembership?.(homeWorkspaceId)?.group?.name
|
||||
})
|
||||
if (!name) throw new Error('Could not resolve home workspace name')
|
||||
return name
|
||||
}
|
||||
|
||||
@step
|
||||
async expectWorkspaceVisible(name: string) {
|
||||
name = clampWorkspaceName(name)
|
||||
// Gate on cross-client sync at the data layer first — immune to dropdown churn — then assert
|
||||
// the switcher renders the workspace. Re-open and re-check together: the membership can be
|
||||
// fully synced (the member may even be active in the workspace) yet the assertion still fails
|
||||
// because a settling re-render dismissed the menu, and a closed switcher has no link items.
|
||||
await this.waitForWorkspaceMembershipSync(name, true)
|
||||
await expect(async () => {
|
||||
await this.ensureWorkspaceSwitcherOpen()
|
||||
await expect(this.getWorkspaceLink(name)).toBeVisible({ timeout: 2000 })
|
||||
}).toPass({ timeout: 20000 })
|
||||
await this.page.keyboard.press('Escape')
|
||||
}
|
||||
|
||||
@step
|
||||
async expectWorkspaceNotVisible(name: string) {
|
||||
name = clampWorkspaceName(name)
|
||||
// Workspace removal (member removed, workspace deleted) reaches an active member via
|
||||
// cross-client sync too. Wait for the membership to leave the data layer first, then confirm
|
||||
// the switcher no longer lists it. Keep the menu open while checking (Home is always present)
|
||||
// so the absence can't pass vacuously against a dropdown that closed under us mid-settle.
|
||||
await this.waitForWorkspaceMembershipSync(name, false)
|
||||
await expect(async () => {
|
||||
await this.ensureWorkspaceSwitcherOpen()
|
||||
await expect(this.getWorkspaceLink(name)).toHaveCount(0)
|
||||
}).toPass({ timeout: 20000 })
|
||||
await this.page.keyboard.press('Escape')
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll the app's data layer until a workspace membership with the given name is present (or
|
||||
* absent). The membership backs the workspace switcher reactively via `getWorkspaceMemberships`,
|
||||
* so once it settles the UI follows immediately — this lets callers gate on cross-client sync
|
||||
* without depending on the dropdown being open at the moment the row arrives.
|
||||
*/
|
||||
private async waitForWorkspaceMembershipSync(name: string, present: boolean) {
|
||||
name = clampWorkspaceName(name)
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
this.page.evaluate((workspaceName) => {
|
||||
const memberships = (window as any).app?.getWorkspaceMemberships?.() ?? []
|
||||
return memberships.some((m: any) => m?.group?.name === workspaceName)
|
||||
}, name),
|
||||
{ timeout: WORKSPACE_MEMBERSHIP_SYNC_TIMEOUT }
|
||||
)
|
||||
.toBe(present)
|
||||
}
|
||||
|
||||
@step
|
||||
async expectActiveWorkspace(name: string) {
|
||||
name = clampWorkspaceName(name)
|
||||
// Switching workspaces navigates to (or creates) a file in the target before the active name
|
||||
// updates, so allow the suite's cross-client budget rather than the default 5s.
|
||||
await expect(this.page.getByTestId('tla-active-workspace-name')).toHaveText(name, {
|
||||
timeout: 10000,
|
||||
})
|
||||
}
|
||||
|
||||
@step
|
||||
async expectActiveHomeWorkspace() {
|
||||
await this.expectActiveWorkspace(await this.getHomeWorkspaceName())
|
||||
}
|
||||
|
||||
@step
|
||||
async switchToWorkspace(name: string) {
|
||||
name = clampWorkspaceName(name)
|
||||
// Re-open and click together: a settling re-render can dismiss the menu between opening it and
|
||||
// clicking the workspace, leaving the click waiting on an item that no longer exists.
|
||||
await expect(async () => {
|
||||
await this.ensureWorkspaceSwitcherOpen()
|
||||
await this.getWorkspaceLink(name).click({ timeout: 2000 })
|
||||
}).toPass({ timeout: 20000 })
|
||||
await this.expectActiveWorkspace(name)
|
||||
}
|
||||
|
||||
@step
|
||||
async switchToHomeWorkspace() {
|
||||
// The home workspace is renameable, so target its stable switcher item rather than a label.
|
||||
await expect(async () => {
|
||||
await this.ensureWorkspaceSwitcherOpen()
|
||||
await this.page.getByTestId('tla-workspace-switcher-home').click({ timeout: 2000 })
|
||||
}).toPass({ timeout: 20000 })
|
||||
await this.expectActiveHomeWorkspace()
|
||||
}
|
||||
|
||||
@step
|
||||
async openWorkspaceSettings(name: string) {
|
||||
name = clampWorkspaceName(name)
|
||||
// The settings action lives in the active workspace's action rows, so
|
||||
// switch to the workspace first if needed.
|
||||
const activeName = await this.page.getByTestId('tla-active-workspace-name').innerText()
|
||||
if (activeName !== name) {
|
||||
await this.switchToWorkspace(name)
|
||||
}
|
||||
await this.page.getByTestId('tla-sidebar-workspace-settings').click()
|
||||
}
|
||||
|
||||
@step
|
||||
async renameWorkspace(oldName: string, newName: string) {
|
||||
newName = clampWorkspaceName(newName)
|
||||
await this.openWorkspaceSettings(oldName)
|
||||
|
||||
// Find the name input and change it (use placeholder as user sees it)
|
||||
const input = this.page.getByPlaceholder('Workspace name')
|
||||
await expect(input).toBeVisible()
|
||||
await input.fill(newName)
|
||||
|
||||
// Close the dialog
|
||||
await this.page.getByRole('button', { name: 'Close' }).click()
|
||||
}
|
||||
|
||||
@step
|
||||
async deleteWorkspace(name: string) {
|
||||
await this.openWorkspaceSettings(name)
|
||||
|
||||
// Delete lives on the Settings tab of the Manage workspace dialog.
|
||||
await this.page.getByRole('tab', { name: 'Settings' }).click()
|
||||
await this.page.getByRole('button', { name: 'Delete workspace', exact: true }).click()
|
||||
|
||||
// Confirm in the confirmation dialog, whose button is just "Delete".
|
||||
await this.page.getByRole('button', { name: 'Delete', exact: true }).click()
|
||||
}
|
||||
|
||||
@step
|
||||
async copyWorkspaceInviteLink(name: string): Promise<string> {
|
||||
// The invite link lives in the Manage workspace dialog and is only exposed via
|
||||
// the Copy button (no visible URL field), so copy it and read it back from the
|
||||
// clipboard. The invite secret can load asynchronously, so poll until valid.
|
||||
await this.openWorkspaceSettings(name)
|
||||
const dialog = this.page.getByRole('dialog', { name: 'Manage workspace' })
|
||||
let inviteUrl = ''
|
||||
await expect(async () => {
|
||||
await dialog.getByRole('button', { name: 'Copy invite link' }).click()
|
||||
inviteUrl = await this.readClipboardUrl(/^\/invite\//)
|
||||
}).toPass({ timeout: 10000 })
|
||||
await this.page.getByRole('button', { name: 'Close' }).click()
|
||||
return inviteUrl
|
||||
}
|
||||
|
||||
// File visibility methods
|
||||
getFileByName(fileName: string) {
|
||||
return this.page.locator('[data-element="file-link"]').filter({ hasText: fileName })
|
||||
}
|
||||
|
||||
@step
|
||||
async expectFileVisible(fileName: string) {
|
||||
// A file can appear in a member's list via cross-client sync (workspace files, shared guest
|
||||
// files), so allow the same propagation budget the rest of the suite uses rather than the
|
||||
// default 5s assertion timeout.
|
||||
await expect(this.getFileByName(fileName)).toBeVisible({
|
||||
timeout: WORKSPACE_MEMBERSHIP_SYNC_TIMEOUT,
|
||||
})
|
||||
}
|
||||
|
||||
@step
|
||||
async expectFileNotVisible(fileName: string) {
|
||||
// File removal (deletion, losing workspace access) also propagates via cross-client sync.
|
||||
await expect(this.getFileByName(fileName)).not.toBeVisible({
|
||||
timeout: WORKSPACE_MEMBERSHIP_SYNC_TIMEOUT,
|
||||
})
|
||||
}
|
||||
|
||||
@step
|
||||
private async openFileMenuByName(fileName: string) {
|
||||
const fileLink = this.getFileByName(fileName)
|
||||
await fileLink.hover()
|
||||
const button = fileLink.getByRole('button')
|
||||
await button.click()
|
||||
}
|
||||
|
||||
@step
|
||||
async dragFileToUnpinnedSection(fileName: string) {
|
||||
const fileElement = this.getFileByName(fileName)
|
||||
const fileBox = await fileElement.boundingBox()
|
||||
const lastFile = this.sidebar.locator('[data-drop-target-id^="file:"]').last()
|
||||
const lastBox = await lastFile.boundingBox()
|
||||
|
||||
if (!fileBox || !lastBox) throw new Error('Could not get bounding boxes')
|
||||
|
||||
// Move to file
|
||||
await this.page.mouse.move(fileBox.x + fileBox.width / 2, fileBox.y + fileBox.height / 2)
|
||||
|
||||
// Press and hold
|
||||
await this.page.mouse.down()
|
||||
|
||||
// Small delay to let browser detect drag intent
|
||||
await this.page.waitForTimeout(100)
|
||||
|
||||
// Drop on the unpinned part of the list, below the pin zone
|
||||
await this.page.mouse.move(lastBox.x + lastBox.width / 2, lastBox.y + lastBox.height / 2, {
|
||||
steps: 10,
|
||||
})
|
||||
|
||||
// Small delay before release
|
||||
await this.page.waitForTimeout(50)
|
||||
|
||||
// Release
|
||||
await this.page.mouse.up()
|
||||
}
|
||||
|
||||
@step
|
||||
async pinFile(fileName: string) {
|
||||
await this.openFileMenuByName(fileName)
|
||||
await this.page.getByRole('menuitem', { name: 'Pin' }).click()
|
||||
}
|
||||
|
||||
@step
|
||||
async unpinFile(fileName: string) {
|
||||
await this.openFileMenuByName(fileName)
|
||||
await this.page.getByRole('menuitem', { name: 'Unpin' }).click()
|
||||
}
|
||||
|
||||
@step
|
||||
async expectFilePinned(fileName: string) {
|
||||
const fileLink = this.getFileByName(fileName)
|
||||
await expect(fileLink).toHaveAttribute('data-is-pinned', 'true')
|
||||
}
|
||||
|
||||
@step
|
||||
async expectFileNotPinned(fileName: string) {
|
||||
const fileLink = this.getFileByName(fileName)
|
||||
await expect(fileLink).toHaveAttribute('data-is-pinned', 'false')
|
||||
}
|
||||
|
||||
@step
|
||||
async expectFileActive(fileName: string) {
|
||||
const fileLink = this.getFileByName(fileName)
|
||||
await expect(fileLink).toHaveAttribute('data-active', 'true')
|
||||
}
|
||||
|
||||
@step
|
||||
async expectFileNotActive(fileName: string) {
|
||||
const fileLink = this.getFileByName(fileName)
|
||||
await expect(fileLink).toHaveAttribute('data-active', 'false')
|
||||
}
|
||||
|
||||
/** The names of the files currently shown in the file list (the active workspace's files). */
|
||||
async getVisibleFiles(): Promise<string[]> {
|
||||
const fileLinks = this.sidebar.locator('[data-element="file-link"]')
|
||||
const count = await fileLinks.count()
|
||||
const fileNames: string[] = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
const fileName = await fileLinks.nth(i).locator('[data-testid*="-name"]').innerText()
|
||||
fileNames.push(fileName)
|
||||
}
|
||||
return fileNames
|
||||
}
|
||||
|
||||
@step
|
||||
async deleteFileByName(fileName: string) {
|
||||
await this.openFileMenuByName(fileName)
|
||||
await this.deleteFromFileMenu()
|
||||
}
|
||||
|
||||
@step
|
||||
async duplicateFileByName(fileName: string, newName?: string) {
|
||||
await this.openFileMenuByName(fileName)
|
||||
await this.duplicateFromFileMenu(newName)
|
||||
}
|
||||
|
||||
/**
|
||||
* One attempt at opening the file menu and its move-to submenu. Every wait in here is
|
||||
* short and bounded so a failed attempt throws quickly and the caller's retry loop gets to
|
||||
* re-open the file menu — the open click itself is what flakes (the sidebar can re-render
|
||||
* mid-click and swallow it), so any recovery must include re-clicking, not just re-waiting.
|
||||
*/
|
||||
private async tryOpenMoveToMenu(fileName: string) {
|
||||
// Close whatever menu/submenu a previous attempt left behind.
|
||||
await this.page.keyboard.press('Escape')
|
||||
await this.page.keyboard.press('Escape')
|
||||
await this.openFileMenuByName(fileName)
|
||||
const moveToButton = this.page.getByTestId('dialog-sub.move-to-workspace-button')
|
||||
await expect(moveToButton).toBeVisible({ timeout: 2000 })
|
||||
await moveToButton.hover({ force: true })
|
||||
await expect(this.page.getByTestId('dialog-sub.move-to-workspace-content')).toBeVisible({
|
||||
timeout: 1000,
|
||||
})
|
||||
}
|
||||
|
||||
@step
|
||||
async openMoveToMenu(fileName: string) {
|
||||
await expect(() => this.tryOpenMoveToMenu(fileName)).toPass({ timeout: 20000 })
|
||||
}
|
||||
|
||||
@step
|
||||
async closeMoveToMenu() {
|
||||
await this.page.keyboard.press('Escape')
|
||||
await this.page.keyboard.press('Escape')
|
||||
}
|
||||
|
||||
@step
|
||||
async moveFileToWorkspace(fileName: string, targetWorkspaceName: string) {
|
||||
targetWorkspaceName = clampWorkspaceName(targetWorkspaceName)
|
||||
// The move-to menu is a checklist: each destination is a checkbox item, and the
|
||||
// destination we're moving to is never the current (checked) one, so its accessible
|
||||
// name is just the workspace name (an unchecked item adds no "checked" prefix).
|
||||
await expect(async () => {
|
||||
await this.tryOpenMoveToMenu(fileName)
|
||||
await this.page
|
||||
.getByRole('menuitemcheckbox', { name: targetWorkspaceName, exact: true })
|
||||
.click({ timeout: 2000 })
|
||||
}).toPass({ timeout: 20000 })
|
||||
}
|
||||
|
||||
@step
|
||||
async moveFileToHome(fileName: string) {
|
||||
await this.moveFileToWorkspace(fileName, await this.getHomeWorkspaceName())
|
||||
}
|
||||
}
|
||||
66
apps/dotcom/client/e2e/fixtures/SignInDialog.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { step } from './tla-test'
|
||||
|
||||
export class SignInDialog {
|
||||
public readonly googleButton: Locator
|
||||
public readonly emailInput: Locator
|
||||
public readonly continueWithEmailButton: Locator
|
||||
public readonly codeInput: Locator
|
||||
public readonly resendButton: Locator
|
||||
public readonly acceptAndContinueButton: Locator
|
||||
|
||||
constructor(private readonly page: Page) {
|
||||
this.googleButton = this.page.getByTestId('tla-google-sign-in-button')
|
||||
this.emailInput = this.page.getByLabel('Email address')
|
||||
this.continueWithEmailButton = this.page.getByTestId('tla-continue-with-email-button')
|
||||
this.codeInput = this.page.locator('#tla-verification-code')
|
||||
this.resendButton = this.page.getByTestId('tla-resend-code-button')
|
||||
this.acceptAndContinueButton = this.page.getByTestId('tla-accept-and-continue-button')
|
||||
}
|
||||
|
||||
@step
|
||||
async expectInitialElements() {
|
||||
await expect(this.googleButton).toBeVisible()
|
||||
await expect(this.emailInput).toBeVisible()
|
||||
await expect(this.continueWithEmailButton).toBeVisible()
|
||||
}
|
||||
|
||||
@step
|
||||
async continueWithEmail(email: string) {
|
||||
await this.emailInput.fill(email)
|
||||
await expect(this.continueWithEmailButton).toBeEnabled()
|
||||
await this.continueWithEmailButton.click()
|
||||
}
|
||||
|
||||
@step
|
||||
async expectCodeStageVisible() {
|
||||
await expect(this.codeInput).toBeVisible()
|
||||
}
|
||||
|
||||
@step
|
||||
async fillCode(code: string) {
|
||||
await this.codeInput.fill(code)
|
||||
}
|
||||
|
||||
@step
|
||||
async clickResend() {
|
||||
await expect(this.resendButton).toBeVisible()
|
||||
await this.resendButton.click()
|
||||
}
|
||||
|
||||
@step
|
||||
async expectTermsStageVisible() {
|
||||
await expect(this.acceptAndContinueButton).toBeVisible()
|
||||
}
|
||||
|
||||
@step
|
||||
async expectAcceptAndContinueDisabled() {
|
||||
await expect(this.acceptAndContinueButton).toBeDisabled()
|
||||
}
|
||||
|
||||
@step
|
||||
async acceptAndContinue() {
|
||||
await this.acceptAndContinueButton.click()
|
||||
}
|
||||
}
|
||||
29
apps/dotcom/client/e2e/fixtures/WorkspaceInviteDialog.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { Locator, Page } from '@playwright/test'
|
||||
import { expect, step } from './tla-test'
|
||||
|
||||
export class WorkspaceInviteDialog {
|
||||
public readonly dialog: Locator
|
||||
public readonly acceptButton: Locator
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.dialog = this.page.getByText('You have been invited to join')
|
||||
this.acceptButton = this.page.getByRole('button', { name: 'Accept invitation' })
|
||||
}
|
||||
|
||||
async isVisible() {
|
||||
return this.dialog.isVisible()
|
||||
}
|
||||
|
||||
async expectIsVisible() {
|
||||
await expect(this.dialog).toBeVisible()
|
||||
}
|
||||
|
||||
async expectIsNotVisible() {
|
||||
await expect(this.dialog).not.toBeVisible()
|
||||
}
|
||||
|
||||
@step
|
||||
async acceptInvitation() {
|
||||
await this.acceptButton.click()
|
||||
}
|
||||
}
|
||||
121
apps/dotcom/client/e2e/fixtures/helpers.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { Browser, BrowserContext, Page, test } from '@playwright/test'
|
||||
import { Editor } from './Editor'
|
||||
import { ErrorPage } from './ErrorPages'
|
||||
import { HomePage } from './HomePage'
|
||||
import { ShareMenu } from './ShareMenu'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { WorkspaceInviteDialog } from './WorkspaceInviteDialog'
|
||||
|
||||
export type UserName = 'huppy' | 'suppy'
|
||||
type UserProps = { user: UserName; index: number } | undefined
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
export async function openNewTab(
|
||||
browser: Browser,
|
||||
opts: { url?: string; userProps: UserProps; allowClipboard?: boolean }
|
||||
) {
|
||||
return await test.step('open new incognito page', async () => {
|
||||
const { url, userProps, allowClipboard } = opts
|
||||
let newContext: BrowserContext
|
||||
if (userProps === undefined) {
|
||||
newContext = await browser.newContext({ storageState: undefined })
|
||||
} else {
|
||||
const storageStateFileName = getStorageStateFileName(userProps.index, userProps.user)
|
||||
const storageState = JSON.parse(fs.readFileSync(storageStateFileName, 'utf-8'))
|
||||
newContext = await browser.newContext({ storageState })
|
||||
}
|
||||
|
||||
if (allowClipboard) {
|
||||
await newContext.grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
}
|
||||
const newPage = await newContext.newPage()
|
||||
const {
|
||||
newSidebar,
|
||||
newEditor,
|
||||
newHomePage,
|
||||
newShareMenu,
|
||||
newWorkspaceInviteDialog,
|
||||
errorPage,
|
||||
} = createFixtures(newPage)
|
||||
if (url) {
|
||||
await newPage.goto(url)
|
||||
} else {
|
||||
await newHomePage.goto()
|
||||
}
|
||||
await newHomePage.isLoaded()
|
||||
return {
|
||||
newPage,
|
||||
newContext,
|
||||
newSidebar,
|
||||
newHomePage,
|
||||
newEditor,
|
||||
newShareMenu,
|
||||
newWorkspaceInviteDialog,
|
||||
errorPage,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function getStorageStateFileName(index: number, user: UserName) {
|
||||
return path.join(__dirname, `../.auth/${user}${index + 1}.json`)
|
||||
}
|
||||
|
||||
export function areUrlsEqual(
|
||||
url1: string,
|
||||
url2: string,
|
||||
opts: { ignoreSearch?: boolean } = { ignoreSearch: true }
|
||||
) {
|
||||
if (opts.ignoreSearch) {
|
||||
const url1Obj = new URL(url1)
|
||||
const url2Obj = new URL(url2)
|
||||
return url1Obj.origin === url2Obj.origin && url1Obj.pathname === url2Obj.pathname
|
||||
}
|
||||
return url1 === url2
|
||||
}
|
||||
|
||||
export function createFixtures(page: Page) {
|
||||
const newSidebar = new Sidebar(page)
|
||||
const newEditor = new Editor(page, newSidebar)
|
||||
const newHomePage = new HomePage(page, newEditor)
|
||||
const newShareMenu = new ShareMenu(page)
|
||||
const newWorkspaceInviteDialog = new WorkspaceInviteDialog(page)
|
||||
const errorPage = new ErrorPage(page)
|
||||
return { newSidebar, newEditor, newHomePage, newShareMenu, newWorkspaceInviteDialog, errorPage }
|
||||
}
|
||||
|
||||
export function getRandomName() {
|
||||
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
|
||||
let result = ''
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const randomIndex = Math.floor(Math.random() * characters.length)
|
||||
result += characters[randomIndex]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const PROPAGATE_CHANGES_TIMEOUT = 100
|
||||
/**
|
||||
* Ensures that the provided expectations are met both before and after a page reload.
|
||||
*
|
||||
* @param fn - An asynchronous function containing the expectations to be tested.
|
||||
* @param page - The Playwright Page object representing the browser page.
|
||||
*
|
||||
* The function performs the following steps:
|
||||
* 1. Executes the provided expectations and ensures they pass.
|
||||
* 2. Waits for a specified timeout to allow optimistic changes to propagate to the server.
|
||||
* 3. Reloads the page.
|
||||
* 4. Executes the provided expectations again and ensures they pass.
|
||||
*/
|
||||
export async function expectBeforeAndAfterReload(fn: () => Promise<void>, page: Page) {
|
||||
await fn()
|
||||
await sleep(PROPAGATE_CHANGES_TIMEOUT)
|
||||
await page.reload()
|
||||
const { newHomePage } = createFixtures(page)
|
||||
await newHomePage.isLoaded()
|
||||
await fn()
|
||||
}
|
||||
695
apps/dotcom/client/e2e/fixtures/scenario-test.ts
Normal file
@@ -0,0 +1,695 @@
|
||||
import { createHash } from 'crypto'
|
||||
import fs from 'fs'
|
||||
import { setupClerkTestingToken } from '@clerk/testing/playwright'
|
||||
import { expect, test as base } from '@playwright/test'
|
||||
import type { Browser, BrowserContext, Download, Locator, Page, TestInfo } from '@playwright/test'
|
||||
import { MAX_WORKSPACE_NAME_LENGTH } from '@tldraw/dotcom-shared'
|
||||
import { NUMBER_OF_USERS } from '../consts'
|
||||
import { Database, getTestUserEmail } from './Database'
|
||||
import { DeleteFileDialog } from './DeleteFileDialog'
|
||||
import { Editor } from './Editor'
|
||||
import { ErrorPage } from './ErrorPages'
|
||||
import { getStorageStateFileName } from './helpers'
|
||||
import { HomePage } from './HomePage'
|
||||
import { ImportHelper } from './ImportHelper'
|
||||
import { ShareMenu } from './ShareMenu'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { SignInDialog } from './SignInDialog'
|
||||
import { WorkspaceInviteDialog } from './WorkspaceInviteDialog'
|
||||
|
||||
type SignedInActorName = 'owner' | 'member'
|
||||
type DotcomActorName = SignedInActorName | 'visitor'
|
||||
type SharedLinkType = 'edit' | 'view' | 'no-access'
|
||||
type WorkspaceMemberRole = 'owner' | 'member'
|
||||
|
||||
const SCENARIO_USER_POOL_START = 4
|
||||
const ROOT_URL = 'http://localhost:3000'
|
||||
const MENU_INTERACTION_TIMEOUT = 5_000
|
||||
|
||||
export async function selectTlaMenuOption(page: Page, select: Locator, optionLabel: string) {
|
||||
await select.click({ timeout: MENU_INTERACTION_TIMEOUT })
|
||||
const openListbox = page.locator('[role="listbox"][data-state="open"]')
|
||||
await expect(openListbox).toBeVisible({ timeout: MENU_INTERACTION_TIMEOUT })
|
||||
await openListbox
|
||||
.getByRole('option', { name: optionLabel, exact: true })
|
||||
.click({ timeout: MENU_INTERACTION_TIMEOUT })
|
||||
await expect(openListbox).not.toBeVisible({ timeout: MENU_INTERACTION_TIMEOUT })
|
||||
}
|
||||
|
||||
interface SignedInActorAccount {
|
||||
email: string
|
||||
storageState: string
|
||||
}
|
||||
|
||||
interface ScenarioWorkerFixtures {
|
||||
actorAccounts: Record<SignedInActorName, SignedInActorAccount>
|
||||
}
|
||||
|
||||
interface ScenarioFixtures {
|
||||
actors: DotcomActors
|
||||
owner: DotcomActor
|
||||
member: DotcomActor
|
||||
visitor: DotcomActor
|
||||
scenario: DotcomScenario
|
||||
setupAndCleanup: void
|
||||
}
|
||||
|
||||
interface LegacyRouteFixture {
|
||||
slug: string
|
||||
readonlySlug: string
|
||||
legacyReadonlySlug: string
|
||||
snapshotSlug: string
|
||||
historyTimestamp: string
|
||||
urls: {
|
||||
room: string
|
||||
readonly: string
|
||||
legacyReadonly: string
|
||||
snapshot: string
|
||||
history: string
|
||||
historySnapshot: string
|
||||
}
|
||||
}
|
||||
|
||||
export class DotcomActor {
|
||||
readonly sidebar: Sidebar
|
||||
readonly editor: Editor
|
||||
readonly homePage: HomePage
|
||||
readonly shareMenu: ShareMenu
|
||||
readonly deleteFileDialog: DeleteFileDialog
|
||||
readonly errorPage: ErrorPage
|
||||
readonly importHelper: ImportHelper
|
||||
readonly signInDialog: SignInDialog
|
||||
readonly workspaceInviteDialog: WorkspaceInviteDialog
|
||||
|
||||
constructor(
|
||||
readonly name: DotcomActorName,
|
||||
readonly page: Page,
|
||||
readonly context: BrowserContext,
|
||||
readonly email?: string
|
||||
) {
|
||||
this.sidebar = new Sidebar(page)
|
||||
this.editor = new Editor(page, this.sidebar)
|
||||
this.homePage = new HomePage(page, this.editor)
|
||||
this.shareMenu = new ShareMenu(page)
|
||||
this.deleteFileDialog = new DeleteFileDialog(page)
|
||||
this.errorPage = new ErrorPage(page)
|
||||
this.importHelper = new ImportHelper(page)
|
||||
this.signInDialog = new SignInDialog(page)
|
||||
this.workspaceInviteDialog = new WorkspaceInviteDialog(page)
|
||||
}
|
||||
|
||||
async goto(url = 'http://localhost:3000/') {
|
||||
await this.homePage.goto(url)
|
||||
await this.waitForAppReady()
|
||||
}
|
||||
|
||||
async waitForAppReady() {
|
||||
await this.homePage.isLoaded()
|
||||
await this.waitForEditorReady()
|
||||
await this.waitForAuthLoaded()
|
||||
await this.waitForAppStoreHydrated()
|
||||
await this.waitForFileRoomConnected()
|
||||
await this.waitForVisitorAccessMetadata()
|
||||
}
|
||||
|
||||
async waitForAuthLoaded() {
|
||||
await this.page.waitForFunction(
|
||||
([isSignedIn]) => {
|
||||
const app = (window as any).app
|
||||
const editor = (window as any).editor
|
||||
const path = window.location.pathname
|
||||
const isAppRoute = path === '/' || path.startsWith('/f/')
|
||||
const isLegacyReadonlyRoute = path.startsWith('/ro/') || path.startsWith('/v/')
|
||||
const hasMountedCanvas = !!document.querySelector('[data-testid="canvas"]')
|
||||
|
||||
if (isSignedIn && isAppRoute) return !!app?.getUser?.()
|
||||
if (isSignedIn && isLegacyReadonlyRoute)
|
||||
return !!app?.getUser?.() || !!editor || hasMountedCanvas
|
||||
if (isSignedIn) return !!app?.getUser?.() || !!editor
|
||||
return (
|
||||
!!document.querySelector('[data-testid="tla-sign-in-button"]') ||
|
||||
!!document.querySelector('[data-testid="tla-sidebar-toggle"]')
|
||||
)
|
||||
},
|
||||
[!!this.email]
|
||||
)
|
||||
}
|
||||
|
||||
async waitForAppStoreHydrated() {
|
||||
if (!this.email) return
|
||||
await this.page.waitForFunction(() => {
|
||||
const app = (window as any).app
|
||||
const path = window.location.pathname
|
||||
const isAppRoute = path === '/' || path.startsWith('/f/')
|
||||
if (!isAppRoute) return true
|
||||
|
||||
return !!app?.getUser?.() && Array.isArray(app?.getUserFileStates?.())
|
||||
})
|
||||
}
|
||||
|
||||
async waitForEditorReady() {
|
||||
await this.page.waitForFunction(() => {
|
||||
const editor = (window as any).editor
|
||||
if (editor?.getCurrentPageId?.()) return true
|
||||
|
||||
const path = window.location.pathname
|
||||
const hasMountedCanvas = !!document.querySelector('[data-testid="canvas"]')
|
||||
return hasMountedCanvas && (path.startsWith('/ro/') || path.startsWith('/v/'))
|
||||
})
|
||||
}
|
||||
|
||||
async waitForFileRoomConnected() {
|
||||
await this.page.waitForFunction(() => {
|
||||
const editor = (window as any).editor
|
||||
if (!editor?.store) {
|
||||
const path = window.location.pathname
|
||||
return (
|
||||
!!document.querySelector('[data-testid="canvas"]') &&
|
||||
(path.startsWith('/ro/') || path.startsWith('/v/'))
|
||||
)
|
||||
}
|
||||
|
||||
const status = editor.store.status
|
||||
return status === undefined || status === 'synced-remote'
|
||||
})
|
||||
}
|
||||
|
||||
async waitForVisitorAccessMetadata() {
|
||||
await this.page.waitForFunction(() => {
|
||||
const editor = (window as any).editor
|
||||
if (!editor?.getIsReadonly) {
|
||||
const path = window.location.pathname
|
||||
return (
|
||||
!!document.querySelector('[data-testid="canvas"]') &&
|
||||
(path.startsWith('/ro/') || path.startsWith('/v/'))
|
||||
)
|
||||
}
|
||||
|
||||
// Calling getIsReadonly proves the mounted editor has received the
|
||||
// current access mode for signed-out visitors and signed-in guests.
|
||||
return typeof editor.getIsReadonly() === 'boolean'
|
||||
})
|
||||
}
|
||||
|
||||
async waitForSessionClosed(timeout = 20000) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
return await this.page.evaluate(() => {
|
||||
const editor = (window as any).editor
|
||||
return editor?.getCollaborators?.().length ?? 0
|
||||
})
|
||||
},
|
||||
{ timeout }
|
||||
)
|
||||
.toBe(0)
|
||||
}
|
||||
|
||||
async getCollaboratorCount() {
|
||||
return await this.page.evaluate(() => (window as any).editor?.getCollaborators?.().length ?? 0)
|
||||
}
|
||||
|
||||
async expectCollaboratorCount(count: number, timeout = 10000) {
|
||||
await expect.poll(() => this.getCollaboratorCount(), { timeout }).toBe(count)
|
||||
}
|
||||
|
||||
async getIsReadonly() {
|
||||
return await this.page.evaluate(() => {
|
||||
const editorReadonly = (window as any).editor?.getIsReadonly?.()
|
||||
if (typeof editorReadonly === 'boolean') return editorReadonly
|
||||
|
||||
const path = window.location.pathname
|
||||
return (
|
||||
!!document.querySelector('[data-testid="canvas"]') &&
|
||||
(path.startsWith('/ro/') || path.startsWith('/v/'))
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async expectReadonly(readonly: boolean, timeout = 10000) {
|
||||
await expect.poll(() => this.getIsReadonly(), { timeout }).toBe(readonly)
|
||||
}
|
||||
|
||||
async close() {
|
||||
await this.context.close()
|
||||
}
|
||||
}
|
||||
|
||||
class DotcomActors {
|
||||
private readonly openActors: DotcomActor[] = []
|
||||
|
||||
constructor(
|
||||
private readonly browser: Browser,
|
||||
private readonly actorAccounts: Record<SignedInActorName, SignedInActorAccount>
|
||||
) {}
|
||||
|
||||
async open(
|
||||
name: DotcomActorName,
|
||||
opts: { url?: string; allowClipboard?: boolean; goto?: boolean } = {}
|
||||
) {
|
||||
const account = name === 'visitor' ? undefined : this.actorAccounts[name]
|
||||
const context = await this.browser.newContext({
|
||||
storageState: account?.storageState,
|
||||
})
|
||||
|
||||
if (opts.allowClipboard ?? true) {
|
||||
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
|
||||
}
|
||||
|
||||
const page = await context.newPage()
|
||||
await setupClerkTestingToken({ page })
|
||||
|
||||
const actor = new DotcomActor(name, page, context, account?.email)
|
||||
this.openActors.push(actor)
|
||||
|
||||
if (opts.goto ?? true) {
|
||||
await actor.goto(opts.url)
|
||||
}
|
||||
|
||||
return actor
|
||||
}
|
||||
|
||||
async closeAll() {
|
||||
await Promise.allSettled(this.openActors.splice(0).map((actor) => actor.close()))
|
||||
}
|
||||
}
|
||||
|
||||
class DotcomScenario {
|
||||
readonly id: string
|
||||
private readonly database: Database
|
||||
|
||||
constructor(private readonly testInfo: TestInfo) {
|
||||
this.id = getScenarioId(testInfo)
|
||||
// Scenario actors live in the scenario user pool, so the database must use the same index
|
||||
// for its index-based helpers to target the right Clerk accounts.
|
||||
this.database = new Database(null, getScenarioUserIndex(testInfo.parallelIndex))
|
||||
}
|
||||
|
||||
name(label: string) {
|
||||
return `${this.id} ${label}`
|
||||
}
|
||||
|
||||
async createPersonalFile(actor: DotcomActor, fileName = this.name('file')) {
|
||||
await actor.editor.ensureSidebarOpen()
|
||||
await this.switchToHomeIfAvailable(actor)
|
||||
await actor.sidebar.createNewDocument(fileName)
|
||||
await actor.sidebar.expectFileActive(fileName)
|
||||
return { fileName, url: actor.page.url() }
|
||||
}
|
||||
|
||||
async createSharedFile(
|
||||
actor: DotcomActor,
|
||||
linkType: Exclude<SharedLinkType, 'no-access'> = 'edit',
|
||||
fileName = this.name(`${linkType} shared file`)
|
||||
) {
|
||||
const file = await this.createPersonalFile(actor, fileName)
|
||||
const sharedUrl = await this.shareFile(actor, linkType)
|
||||
return { ...file, sharedUrl, linkType }
|
||||
}
|
||||
|
||||
async createGuestFile(opts: {
|
||||
owner: DotcomActor
|
||||
guest: DotcomActor
|
||||
linkType: Exclude<SharedLinkType, 'no-access'>
|
||||
fileName?: string
|
||||
}) {
|
||||
const file = await this.createSharedFile(opts.owner, opts.linkType, opts.fileName)
|
||||
await opts.guest.goto(file.sharedUrl)
|
||||
return file
|
||||
}
|
||||
|
||||
async createGuestEditFile(owner: DotcomActor, guest: DotcomActor, fileName?: string) {
|
||||
return await this.createGuestFile(
|
||||
fileName === undefined
|
||||
? { owner, guest, linkType: 'edit' }
|
||||
: { owner, guest, fileName, linkType: 'edit' }
|
||||
)
|
||||
}
|
||||
|
||||
async createGuestViewFile(owner: DotcomActor, guest: DotcomActor, fileName?: string) {
|
||||
return await this.createGuestFile(
|
||||
fileName === undefined
|
||||
? { owner, guest, linkType: 'view' }
|
||||
: { owner, guest, fileName, linkType: 'view' }
|
||||
)
|
||||
}
|
||||
|
||||
async createRectangle(actor: DotcomActor) {
|
||||
await actor.page.getByTestId('tools.rectangle').click()
|
||||
await actor.page.locator('.tl-background').click()
|
||||
await actor.editor.expectShapesCount(1)
|
||||
}
|
||||
|
||||
async shareFile(actor: DotcomActor, linkType: Exclude<SharedLinkType, 'no-access'> = 'edit') {
|
||||
await actor.shareMenu.open()
|
||||
await this.setSharedLinkType(actor, linkType)
|
||||
const url = await actor.shareMenu.copyLink()
|
||||
await actor.page.keyboard.press('Escape')
|
||||
return url
|
||||
}
|
||||
|
||||
async setSharedLinkType(actor: DotcomActor, linkType: SharedLinkType) {
|
||||
if (!(await actor.shareMenu.inviteTabButton.isVisible().catch(() => false))) {
|
||||
await actor.shareMenu.open()
|
||||
}
|
||||
await actor.shareMenu.ensureTabSelected('invite')
|
||||
|
||||
if (linkType === 'no-access') {
|
||||
await actor.page.getByRole('switch').uncheck()
|
||||
return
|
||||
}
|
||||
|
||||
await actor.page.getByRole('switch').check()
|
||||
const select = actor.page.getByTestId('shared-link-type-select')
|
||||
const expectedLabel = linkType === 'edit' ? 'Editor' : 'Viewer'
|
||||
if ((await select.innerText()) !== expectedLabel) {
|
||||
await selectTlaMenuOption(actor.page, select, expectedLabel)
|
||||
}
|
||||
await expect(select).toHaveText(expectedLabel)
|
||||
}
|
||||
|
||||
async publishFile(actor: DotcomActor) {
|
||||
await actor.shareMenu.open()
|
||||
await actor.shareMenu.publishFile()
|
||||
const url = await actor.shareMenu.copyLink()
|
||||
await actor.page.keyboard.press('Escape')
|
||||
return url
|
||||
}
|
||||
|
||||
async createPublishedFile(actor: DotcomActor, fileName = this.name('published file')) {
|
||||
const file = await this.createPersonalFile(actor, fileName)
|
||||
const publishedUrl = await this.publishFile(actor)
|
||||
return { ...file, publishedUrl }
|
||||
}
|
||||
|
||||
async importFileFromUrl(actor: DotcomActor, url?: string) {
|
||||
await actor.importHelper.mockUrl(url)
|
||||
await actor.importHelper.navigate(url)
|
||||
await actor.page.waitForURL(/\/f\//)
|
||||
await actor.waitForAppReady()
|
||||
return { fileName: await actor.editor.getCurrentFileName(), url: actor.page.url() }
|
||||
}
|
||||
|
||||
async downloadFileFromSidebar(actor: DotcomActor, fileName: string): Promise<Download> {
|
||||
await actor.editor.ensureSidebarOpen()
|
||||
const fileLink = actor.sidebar.getFileByName(fileName)
|
||||
await fileLink.hover()
|
||||
await fileLink.getByRole('button').click()
|
||||
|
||||
const downloadPromise = actor.page.waitForEvent('download')
|
||||
await actor.page.getByRole('menuitem', { name: 'Download' }).click()
|
||||
return await downloadPromise
|
||||
}
|
||||
|
||||
async publishChanges(actor: DotcomActor) {
|
||||
if (!(await actor.shareMenu.publishTabButton.isVisible().catch(() => false))) {
|
||||
await actor.shareMenu.open()
|
||||
}
|
||||
await actor.shareMenu.publishChanges()
|
||||
await actor.page.keyboard.press('Escape')
|
||||
}
|
||||
|
||||
async createLegacyRouteFixture(actor: DotcomActor): Promise<LegacyRouteFixture> {
|
||||
const slug = this.id.slice(0, 96)
|
||||
let fixture: Omit<LegacyRouteFixture, 'urls'> | null = null
|
||||
let lastError = ''
|
||||
|
||||
for (let attempt = 0; attempt < 30; attempt++) {
|
||||
const response = await actor.page.request.post(`${ROOT_URL}/api/app/__test__/legacy-room`, {
|
||||
data: {
|
||||
slug,
|
||||
readonlySlug: `${slug}-ro`,
|
||||
legacyReadonlySlug: `${slug}-v`,
|
||||
snapshotSlug: `v2_c_${slug}`,
|
||||
historyTimestamp: '2020-01-02T03:04:05.000Z',
|
||||
},
|
||||
})
|
||||
|
||||
if (response.ok()) {
|
||||
fixture = (await response.json()) as Omit<LegacyRouteFixture, 'urls'>
|
||||
break
|
||||
}
|
||||
|
||||
lastError = `${response.status()} ${await response.text()}`
|
||||
await actor.page.waitForTimeout(500)
|
||||
}
|
||||
|
||||
if (!fixture) throw new Error(`Legacy route fixture was not created: ${lastError}`)
|
||||
return {
|
||||
...fixture,
|
||||
urls: {
|
||||
room: `${ROOT_URL}/r/${fixture.slug}`,
|
||||
readonly: `${ROOT_URL}/ro/${fixture.readonlySlug}`,
|
||||
legacyReadonly: `${ROOT_URL}/v/${fixture.legacyReadonlySlug}`,
|
||||
snapshot: `${ROOT_URL}/s/${fixture.snapshotSlug}`,
|
||||
history: `${ROOT_URL}/r/${fixture.slug}/history`,
|
||||
historySnapshot: `${ROOT_URL}/r/${fixture.slug}/history/${fixture.historyTimestamp}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async createWorkspaceWithMember(opts: {
|
||||
owner: DotcomActor
|
||||
member: DotcomActor
|
||||
workspaceName?: string
|
||||
fileName?: string
|
||||
}) {
|
||||
const invite = await this.createPendingWorkspaceInvite(opts)
|
||||
|
||||
await opts.member.goto(invite.inviteUrl)
|
||||
await opts.member.editor.ensureSidebarOpen()
|
||||
await opts.member.workspaceInviteDialog.acceptInvitation()
|
||||
await opts.member.sidebar.expectWorkspaceVisible(invite.workspaceName)
|
||||
await opts.member.sidebar.switchToWorkspace(invite.workspaceName)
|
||||
await opts.member.sidebar.expectFileVisible(invite.fileName)
|
||||
|
||||
return invite
|
||||
}
|
||||
|
||||
async createWorkspaceWithRemovedMember(opts: {
|
||||
owner: DotcomActor
|
||||
member: DotcomActor
|
||||
workspaceName?: string
|
||||
fileName?: string
|
||||
}) {
|
||||
const workspace = await this.createWorkspaceWithMember(opts)
|
||||
await this.removeWorkspaceMember({
|
||||
owner: opts.owner,
|
||||
workspaceName: workspace.workspaceName,
|
||||
memberUserId: workspace.memberUserId,
|
||||
})
|
||||
return workspace
|
||||
}
|
||||
|
||||
async createPendingWorkspaceInvite(opts: {
|
||||
owner: DotcomActor
|
||||
member: DotcomActor
|
||||
workspaceName?: string
|
||||
fileName?: string
|
||||
}) {
|
||||
// The createWorkspace mutator clamps names to MAX_WORKSPACE_NAME_LENGTH, so mirror that here:
|
||||
// otherwise a long scenario id makes the stored (and displayed) name diverge from what the
|
||||
// sidebar helpers search for, and the workspace link never matches.
|
||||
const workspaceName = (opts.workspaceName ?? this.name('workspace'))
|
||||
.trim()
|
||||
.slice(0, MAX_WORKSPACE_NAME_LENGTH)
|
||||
const fileName = opts.fileName ?? this.name('workspace file')
|
||||
|
||||
await this.goToAndOpenSidebar(opts.owner)
|
||||
await this.goToAndOpenSidebar(opts.member)
|
||||
|
||||
if (!opts.member.email) throw new Error('Workspace member actor is not signed in')
|
||||
const memberUserId = await this.database.getUserIdByEmail(opts.member.email)
|
||||
if (!memberUserId) throw new Error(`Member user not found: ${opts.member.email}`)
|
||||
|
||||
await opts.owner.editor.ensureSidebarOpen()
|
||||
await this.switchToHomeIfAvailable(opts.owner)
|
||||
const { fileId } = await opts.owner.page.evaluate(
|
||||
async ({ workspaceName, fileName }) => {
|
||||
const app = (window as any).app
|
||||
const uniqueId = () => {
|
||||
const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqrstuvwxyz-'
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(21))
|
||||
return Array.from(bytes, (byte) => alphabet[byte & 63]).join('')
|
||||
}
|
||||
const workspaceId = uniqueId()
|
||||
const fileId = uniqueId()
|
||||
// Await server acknowledgement (not just optimistic apply) so the file exists
|
||||
// server-side before we navigate to it. These promises never reject — a rejected
|
||||
// mutation resolves with {type: 'error'} — so check the result or a failure
|
||||
// surfaces later as an unrelated timeout.
|
||||
const workspaceRes = await app.z.mutate.createWorkspace({
|
||||
id: workspaceId,
|
||||
name: workspaceName,
|
||||
}).server
|
||||
if (workspaceRes.type !== 'success') {
|
||||
throw new Error(`createWorkspace rejected: ${JSON.stringify(workspaceRes)}`)
|
||||
}
|
||||
const fileRes = await app.z.mutate.createFile({
|
||||
fileId,
|
||||
workspaceId,
|
||||
name: fileName,
|
||||
createSource: null,
|
||||
time: Date.now(),
|
||||
}).server
|
||||
if (fileRes.type !== 'success') {
|
||||
throw new Error(`createFile rejected: ${JSON.stringify(fileRes)}`)
|
||||
}
|
||||
return { fileId }
|
||||
},
|
||||
{ workspaceName, fileName }
|
||||
)
|
||||
await opts.owner.goto(`${ROOT_URL}/f/${fileId}`)
|
||||
await opts.owner.editor.ensureSidebarOpen()
|
||||
|
||||
const inviteUrl = await opts.owner.sidebar.copyWorkspaceInviteLink(workspaceName)
|
||||
|
||||
return { workspaceName, fileName, inviteUrl, memberUserId }
|
||||
}
|
||||
|
||||
async removeWorkspaceMember(opts: {
|
||||
owner: DotcomActor
|
||||
workspaceName: string
|
||||
memberUserId: string
|
||||
}) {
|
||||
await opts.owner.sidebar.openWorkspaceSettings(opts.workspaceName)
|
||||
await selectTlaMenuOption(
|
||||
opts.owner.page,
|
||||
opts.owner.page.locator(`[id="workspace-member-role-${opts.memberUserId}"]`),
|
||||
'Remove'
|
||||
)
|
||||
// The remove confirmation dialog's button is just "Remove".
|
||||
await opts.owner.page.getByRole('button', { name: 'Remove', exact: true }).click()
|
||||
await opts.owner.page.keyboard.press('Escape')
|
||||
}
|
||||
|
||||
async setWorkspaceMemberRole(opts: {
|
||||
owner: DotcomActor
|
||||
workspaceName: string
|
||||
memberUserId: string
|
||||
role: WorkspaceMemberRole
|
||||
}) {
|
||||
await opts.owner.sidebar.openWorkspaceSettings(opts.workspaceName)
|
||||
const memberRoleSelect = opts.owner.page.locator(
|
||||
`[id="workspace-member-role-${opts.memberUserId}"]`
|
||||
)
|
||||
const roleLabel = opts.role === 'owner' ? 'Owner' : 'Member'
|
||||
await selectTlaMenuOption(opts.owner.page, memberRoleSelect, roleLabel)
|
||||
await expect(memberRoleSelect).toHaveText(roleLabel)
|
||||
await opts.owner.page.keyboard.press('Escape')
|
||||
}
|
||||
|
||||
async goToAndOpenSidebar(actor: DotcomActor) {
|
||||
if (!actor.email) throw new Error(`Actor ${actor.name} is not signed in`)
|
||||
|
||||
await actor.goto()
|
||||
await actor.editor.ensureSidebarOpen()
|
||||
}
|
||||
|
||||
private async switchToHomeIfAvailable(actor: DotcomActor) {
|
||||
if (
|
||||
await actor.page
|
||||
.getByTestId('tla-workspace-switcher')
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
) {
|
||||
await actor.sidebar.switchToHomeWorkspace()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const test = base.extend<ScenarioFixtures, ScenarioWorkerFixtures>({
|
||||
actorAccounts: [
|
||||
async ({ browser }, testUse, workerInfo) => {
|
||||
const userIndex = getScenarioUserIndex(workerInfo.parallelIndex)
|
||||
const owner = await ensureStorageState(browser, userIndex, 'huppy')
|
||||
const member = await ensureStorageState(browser, userIndex, 'suppy')
|
||||
await testUse({ owner, member })
|
||||
},
|
||||
{ scope: 'worker' },
|
||||
],
|
||||
setupAndCleanup: [
|
||||
async ({ actorAccounts: _actorAccounts }, testUse, testInfo) => {
|
||||
const database = new Database(null, getScenarioUserIndex(testInfo.parallelIndex))
|
||||
await database.reset()
|
||||
await testUse()
|
||||
},
|
||||
{ auto: true },
|
||||
],
|
||||
actors: async ({ browser, actorAccounts }, testUse) => {
|
||||
const actors = new DotcomActors(browser, actorAccounts)
|
||||
await testUse(actors)
|
||||
await actors.closeAll()
|
||||
},
|
||||
owner: async ({ actors }, testUse) => {
|
||||
await testUse(await actors.open('owner'))
|
||||
},
|
||||
member: async ({ actors }, testUse) => {
|
||||
await testUse(await actors.open('member'))
|
||||
},
|
||||
visitor: async ({ actors }, testUse) => {
|
||||
await testUse(await actors.open('visitor'))
|
||||
},
|
||||
scenario: async ({ browser: _browser }, testUse, testInfo) => {
|
||||
await testUse(new DotcomScenario(testInfo))
|
||||
},
|
||||
})
|
||||
|
||||
async function ensureStorageState(
|
||||
browser: Browser,
|
||||
parallelIndex: number,
|
||||
user: 'huppy' | 'suppy'
|
||||
): Promise<SignedInActorAccount> {
|
||||
const storageState = getStorageStateFileName(parallelIndex, user)
|
||||
const email = getTestUserEmail(parallelIndex, user)
|
||||
if (fs.existsSync(storageState)) {
|
||||
return { email, storageState }
|
||||
}
|
||||
|
||||
await base.step(`Sign in ${user} actor`, async () => {
|
||||
const page = await browser.newPage({ storageState: undefined })
|
||||
await setupClerkTestingToken({ page })
|
||||
const sidebar = new Sidebar(page)
|
||||
const editor = new Editor(page, sidebar)
|
||||
const homePage = new HomePage(page, editor)
|
||||
|
||||
await homePage.loginAs(email)
|
||||
await expect(page.getByTestId('tla-sidebar-layout')).toBeVisible()
|
||||
await page.context().storageState({ path: storageState })
|
||||
await page.close()
|
||||
})
|
||||
|
||||
return { email, storageState }
|
||||
}
|
||||
|
||||
function getScenarioId(testInfo: TestInfo) {
|
||||
const fullSlug = testInfo.titlePath
|
||||
.slice(1)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
|
||||
// Keep the id compact: workspace names are capped at MAX_WORKSPACE_NAME_LENGTH, and a name is
|
||||
// `${id} ${label}`, so a long id leaves no room for a distinguishing label — two labels that
|
||||
// differ only past the cap would collide once the mutator clamps the stored name. A short
|
||||
// readable prefix keeps debugging tractable; a hash of the full title preserves the cross-test
|
||||
// uniqueness that a truncated prefix alone would lose.
|
||||
const titleHash = createHash('sha1').update(fullSlug).digest('hex').slice(0, 6)
|
||||
const slug = `${fullSlug.slice(0, 24).replace(/-+$/g, '')}-${titleHash}`
|
||||
|
||||
const runId = process.env.GITHUB_RUN_ID ?? Date.now().toString(36)
|
||||
return `e2e-${runId}-w${testInfo.parallelIndex}-x${testInfo.repeatEachIndex}-r${testInfo.retry}-${slug}`
|
||||
}
|
||||
|
||||
function getScenarioUserIndex(parallelIndex: number) {
|
||||
const userIndex = SCENARIO_USER_POOL_START + parallelIndex
|
||||
if (userIndex >= NUMBER_OF_USERS) {
|
||||
throw new Error(
|
||||
`Not enough Clerk test users for scenario worker ${parallelIndex}. Add more test users or lower the Playwright worker count.`
|
||||
)
|
||||
}
|
||||
return userIndex
|
||||
}
|
||||
|
||||
export { expect } from '@playwright/test'
|
||||
39
apps/dotcom/client/e2e/fixtures/staging-test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { setupClerkTestingToken } from '@clerk/testing/playwright'
|
||||
import { test as base } from '@playwright/test'
|
||||
import { Editor } from './Editor'
|
||||
import { HomePage } from './HomePage'
|
||||
import { Sidebar } from './Sidebar'
|
||||
|
||||
interface StagingFixtures {
|
||||
editor: Editor
|
||||
sidebar: Sidebar
|
||||
homePage: HomePage
|
||||
setupAndCleanup: void
|
||||
}
|
||||
|
||||
const STAGING_URL = 'https://staging.tldraw.com/'
|
||||
|
||||
export const test = base.extend<StagingFixtures>({
|
||||
sidebar: async ({ page }, testUse) => {
|
||||
await testUse(new Sidebar(page))
|
||||
},
|
||||
editor: async ({ page, sidebar }, testUse) => {
|
||||
await testUse(new Editor(page, sidebar))
|
||||
},
|
||||
homePage: async ({ page, editor }, testUse) => {
|
||||
await testUse(new HomePage(page, editor))
|
||||
},
|
||||
setupAndCleanup: [
|
||||
async ({ page, homePage }, testUse) => {
|
||||
await setupClerkTestingToken({ page })
|
||||
|
||||
await homePage.goto(STAGING_URL)
|
||||
await homePage.isLoaded()
|
||||
|
||||
await testUse()
|
||||
},
|
||||
{ auto: true },
|
||||
],
|
||||
})
|
||||
|
||||
export { expect } from '@playwright/test'
|
||||
203
apps/dotcom/client/e2e/fixtures/tla-test.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import fs from 'fs'
|
||||
import { setupClerkTestingToken } from '@clerk/testing/playwright'
|
||||
import { test as base, expect } from '@playwright/test'
|
||||
import { OTHER_USERS, USERS } from '../consts'
|
||||
import { Database } from './Database'
|
||||
import { DeleteFileDialog } from './DeleteFileDialog'
|
||||
import { Editor } from './Editor'
|
||||
import { getStorageStateFileName } from './helpers'
|
||||
import { HomePage } from './HomePage'
|
||||
import { ImportHelper } from './ImportHelper'
|
||||
import { ShareMenu } from './ShareMenu'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import { SignInDialog } from './SignInDialog'
|
||||
import { WorkspaceInviteDialog } from './WorkspaceInviteDialog'
|
||||
|
||||
interface TlaFixtures {
|
||||
homePage: HomePage
|
||||
editor: Editor
|
||||
sidebar: Sidebar
|
||||
shareMenu: ShareMenu
|
||||
database: Database
|
||||
deleteFileDialog: DeleteFileDialog
|
||||
workspaceInviteDialog: WorkspaceInviteDialog
|
||||
signInDialog: SignInDialog
|
||||
importHelper: ImportHelper
|
||||
setupAndCleanup: void
|
||||
retry(fn: () => Promise<void>): Promise<void>
|
||||
}
|
||||
|
||||
interface TlaWorkerFixtures {
|
||||
workerStorageState: string
|
||||
}
|
||||
|
||||
export const test = base.extend<TlaFixtures, TlaWorkerFixtures>({
|
||||
sidebar: async ({ page }, testUse) => {
|
||||
await testUse(new Sidebar(page))
|
||||
},
|
||||
editor: async ({ page, sidebar }, testUse) => {
|
||||
await testUse(new Editor(page, sidebar))
|
||||
},
|
||||
homePage: async ({ page, editor }, testUse) => {
|
||||
await testUse(new HomePage(page, editor))
|
||||
},
|
||||
database: async ({ page }, testUse) => {
|
||||
await testUse(new Database(page, test.info().parallelIndex))
|
||||
},
|
||||
shareMenu: async ({ page }, testUse) => {
|
||||
await testUse(new ShareMenu(page))
|
||||
},
|
||||
deleteFileDialog: async ({ page }, testUse) => {
|
||||
await testUse(new DeleteFileDialog(page))
|
||||
},
|
||||
workspaceInviteDialog: async ({ page }, testUse) => {
|
||||
await testUse(new WorkspaceInviteDialog(page))
|
||||
},
|
||||
signInDialog: async ({ page }, testUse) => {
|
||||
await testUse(new SignInDialog(page))
|
||||
},
|
||||
importHelper: async ({ page }, testUse) => {
|
||||
await testUse(new ImportHelper(page))
|
||||
},
|
||||
// This is an auto fixture which makes sure that we are on the home page when the test starts
|
||||
// and that we clean up when the tests completes
|
||||
setupAndCleanup: [
|
||||
async ({ page, homePage, database }, testUse) => {
|
||||
// All the code before testUse is run before each test
|
||||
|
||||
// Make sure we don't get marked as a bot
|
||||
// https://clerk.com/docs/testing/playwright/overview
|
||||
await setupClerkTestingToken({ page })
|
||||
await database.reset()
|
||||
|
||||
await homePage.goto()
|
||||
await homePage.isLoaded()
|
||||
|
||||
await testUse()
|
||||
},
|
||||
{ auto: true },
|
||||
],
|
||||
storageState: ({ workerStorageState }, testUse) => testUse(workerStorageState),
|
||||
workerStorageState: [
|
||||
async ({ browser }, testUse) => {
|
||||
const id = test.info().parallelIndex
|
||||
|
||||
const fileName = getStorageStateFileName(id, 'huppy')
|
||||
if (fs.existsSync(fileName)) {
|
||||
// Reuse existing authentication state if any.
|
||||
await testUse(fileName)
|
||||
return
|
||||
}
|
||||
for (const user of ['huppy' as const, 'suppy' as const]) {
|
||||
const fileName = getStorageStateFileName(id, user)
|
||||
// Important: make sure we authenticate in a clean environment by unsetting storage state.
|
||||
const page = await browser.newPage({ storageState: undefined })
|
||||
let email: string
|
||||
if (user === 'huppy') {
|
||||
email = USERS[id]
|
||||
} else {
|
||||
email = OTHER_USERS[id]
|
||||
}
|
||||
const sidebar = new Sidebar(page)
|
||||
const editor = new Editor(page, sidebar)
|
||||
const homePage = new HomePage(page, editor)
|
||||
await homePage.loginAs(email)
|
||||
await expect(page.getByTestId('tla-sidebar-layout')).toBeVisible()
|
||||
|
||||
await page.context().storageState({ path: fileName })
|
||||
await page.close()
|
||||
}
|
||||
await testUse(fileName)
|
||||
},
|
||||
{ scope: 'worker' },
|
||||
],
|
||||
})
|
||||
|
||||
export { expect } from '@playwright/test'
|
||||
|
||||
/** Stage 3 / TS 5.2+ method decorator context (subset; avoids coupling to a specific TS lib version). */
|
||||
interface StepMethodDecoratorContext {
|
||||
kind: 'method'
|
||||
name: string | symbol
|
||||
}
|
||||
|
||||
function isStage3MethodDecoratorContext(value: unknown): value is StepMethodDecoratorContext {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'kind' in value &&
|
||||
(value as StepMethodDecoratorContext).kind === 'method'
|
||||
)
|
||||
}
|
||||
|
||||
/** Legacy method decorator (`experimentalDecorators`). */
|
||||
export function step<T extends (...args: any[]) => any>(
|
||||
target: object,
|
||||
propertyKey: string | symbol,
|
||||
descriptor: TypedPropertyDescriptor<T>
|
||||
): TypedPropertyDescriptor<T>
|
||||
/** Stage 3 method decorator (e.g. Node `--no-strip-types`). */
|
||||
export function step<T extends (...args: any[]) => any>(
|
||||
originalMethod: T,
|
||||
context: StepMethodDecoratorContext
|
||||
): T
|
||||
/**
|
||||
* Wraps page-object methods in `test.step` for clearer Playwright reports.
|
||||
* Supports both legacy (`experimentalDecorators`) and Stage 3 method decorators
|
||||
* (e.g. when tests run under Node's `--no-strip-types` + modern emit).
|
||||
*/
|
||||
export function step(
|
||||
target: object | ((...args: any[]) => any),
|
||||
propertyKeyOrContext: string | symbol | StepMethodDecoratorContext,
|
||||
descriptor?: TypedPropertyDescriptor<(...args: any[]) => any>
|
||||
): any {
|
||||
if (
|
||||
descriptor === undefined &&
|
||||
isStage3MethodDecoratorContext(propertyKeyOrContext) &&
|
||||
typeof target === 'function'
|
||||
) {
|
||||
const original = target as (...args: any[]) => any
|
||||
const ctx = propertyKeyOrContext
|
||||
const key = typeof ctx.name === 'string' ? ctx.name : String(ctx.name)
|
||||
return async function (this: object, ...args: any[]) {
|
||||
return await test.step(`${(this as { constructor: { name: string } }).constructor.name}.${key}`, async () => {
|
||||
return original.apply(this, args)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!descriptor?.value) {
|
||||
throw new Error(
|
||||
'step: expected a legacy method decorator with descriptor.value, or a Stage 3 method decorator'
|
||||
)
|
||||
}
|
||||
|
||||
const propertyKey = propertyKeyOrContext as string
|
||||
const original = descriptor.value
|
||||
descriptor.value = async function (this: object, ...args: any[]) {
|
||||
return await test.step(`${(this as { constructor: { name: string } }).constructor.name}.${String(propertyKey)}`, async () => {
|
||||
return original.apply(this, args)
|
||||
})
|
||||
}
|
||||
return descriptor
|
||||
}
|
||||
|
||||
export function repeatTest(
|
||||
name: string,
|
||||
fn: (...args: any) => Promise<void>,
|
||||
{ times = 5, only = false } = {}
|
||||
) {
|
||||
const getName = (i: number) => `${name} (${i + 1} of ${times})`
|
||||
for (let i = 0; i < times; i++) {
|
||||
if (only) {
|
||||
// eslint-disable-next-line tldraw/no-focused-tests
|
||||
test.only(getName(i), fn)
|
||||
} else {
|
||||
test(getName(i), fn)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
repeatTest.only = (name: string, fn: (...args: any) => Promise<void>, { times = 5 } = {}) =>
|
||||
repeatTest(name, fn, { times, only: true })
|
||||
49
apps/dotcom/client/e2e/global-staging.setup.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { clerkSetup } from '@clerk/testing/playwright'
|
||||
import { chromium, expect, test as setup } from '@playwright/test'
|
||||
import { Editor } from './fixtures/Editor'
|
||||
import { HomePage } from './fixtures/HomePage'
|
||||
import { Sidebar } from './fixtures/Sidebar'
|
||||
|
||||
// eslint-disable-next-line no-empty-pattern
|
||||
setup('clerk setup', async ({}) => {
|
||||
await clerkSetup()
|
||||
})
|
||||
|
||||
setup('global staging setup', async () => {
|
||||
const stagingStoragePath = path.join(__dirname, '.auth/staging.json')
|
||||
|
||||
if (fs.existsSync(stagingStoragePath)) {
|
||||
console.warn('Staging auth already exists, skipping setup')
|
||||
return
|
||||
}
|
||||
|
||||
const email = process.env.STAGING_TEST_EMAIL
|
||||
const password = process.env.STAGING_TEST_PASSWORD
|
||||
|
||||
if (!email || !password) {
|
||||
throw new Error(
|
||||
'STAGING_TEST_EMAIL and STAGING_TEST_PASSWORD not set, skipping staging auth setup'
|
||||
)
|
||||
}
|
||||
|
||||
const browser = await chromium.launch()
|
||||
const page = await browser.newPage()
|
||||
|
||||
const sidebar = new Sidebar(page)
|
||||
const editor = new Editor(page, sidebar)
|
||||
const homePage = new HomePage(page, editor)
|
||||
|
||||
await homePage.goto('https://staging.tldraw.com/')
|
||||
await homePage.loginWithEmailAndPassword(email, password)
|
||||
await expect(page.getByTestId('tla-sidebar-layout')).toBeVisible()
|
||||
|
||||
const authDir = path.dirname(stagingStoragePath)
|
||||
if (!fs.existsSync(authDir)) {
|
||||
fs.mkdirSync(authDir, { recursive: true })
|
||||
}
|
||||
|
||||
await page.context().storageState({ path: stagingStoragePath })
|
||||
await browser.close()
|
||||
})
|
||||
9
apps/dotcom/client/e2e/global.setup.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { clerkSetup } from '@clerk/testing/playwright'
|
||||
import { test as setup } from '@playwright/test'
|
||||
|
||||
// delete the e2e/auth directory
|
||||
|
||||
// eslint-disable-next-line no-empty-pattern
|
||||
setup('clerk setup', async ({}) => {
|
||||
await clerkSetup()
|
||||
})
|
||||