├── .eslintrc.js ├── .github └── workflows │ ├── ci-image.yml │ ├── ci.yml │ └── dendrite-image.yml ├── .gitignore ├── LICENSE ├── README.md ├── core ├── .npmignore ├── api │ ├── adapters │ │ ├── element-web.ts │ │ ├── hydrogen.ts │ │ ├── index.ts │ │ └── utils │ │ │ ├── io.ts │ │ │ └── time.ts │ ├── harness.ts │ ├── rpc.ts │ ├── test.ts │ └── utils.ts ├── bin │ └── patience.js ├── framework │ ├── @types │ │ ├── global.d.ts │ │ └── preact.d.ts │ ├── components │ │ ├── client-frames.tsx │ │ ├── frames │ │ │ ├── element-web.tsx │ │ │ ├── frame.tsx │ │ │ ├── hydrogen.tsx │ │ │ └── index.ts │ │ ├── timeline.tsx │ │ └── zoom-toolbar.tsx │ ├── index.css │ ├── index.html │ ├── index.tsx │ └── stores │ │ ├── client.ts │ │ └── timeline.ts ├── package.json ├── tsconfig.json ├── types │ └── client.ts ├── vite.config.ts └── web-test-runner.config.js ├── dockerfiles └── ci.dockerfile ├── example.png ├── examples ├── .eslintrc.js ├── README.md ├── federated.ts ├── hello.ts ├── package.json ├── tsconfig.json └── utils │ └── sleep.ts ├── package-lock.json ├── package.json └── scripts └── release.sh /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | "matrix-org", 4 | "react", 5 | ], 6 | env: { 7 | es2020: true, 8 | browser: true, 9 | node: true, 10 | }, 11 | parserOptions: { 12 | sourceType: "module", 13 | }, 14 | overrides: [ 15 | { 16 | files: [ 17 | "**/*.ts", 18 | "**/*.tsx", 19 | ], 20 | extends: [ 21 | "plugin:matrix-org/typescript", 22 | ], 23 | settings: { 24 | react: { 25 | pragma: "h", 26 | }, 27 | }, 28 | rules: { 29 | "quotes": ["error", "double"], 30 | 31 | // React rules adapted from eslint-plugin-matrix-org 32 | // Rules for hooks removed to avoid extra dependencies 33 | 34 | "max-len": ["warn", { 35 | // Ignore pure JSX lines 36 | ignorePattern: "^\\s*<", 37 | ignoreComments: true, 38 | code: 120, 39 | }], 40 | 41 | // This just uses the React plugin to help ESLint known when 42 | // variables have been used in JSX 43 | "react/jsx-uses-vars": ["error"], 44 | // Don't mark React as unused if we're using JSX 45 | "react/jsx-uses-react": ["error"], 46 | 47 | // Components in JSX should always be defined 48 | "react/jsx-no-undef": ["error"], 49 | 50 | // Assert spacing before self-closing JSX tags, and no spacing before 51 | // or after the closing slash, and no spacing after the opening 52 | // bracket of the opening tag or closing tag. 53 | // https://github.com/yannickcr/eslint-plugin-react/blob/HEAD/docs/rules/jsx-tag-spacing.md 54 | "react/jsx-tag-spacing": ["error"], 55 | 56 | // Empty interfaces are useful for declaring new names 57 | "@typescript-eslint/no-empty-interface": ["off"], 58 | 59 | // Always use `import type` for type-only imports 60 | "@typescript-eslint/consistent-type-imports": ["error"], 61 | }, 62 | }, 63 | { 64 | files: [ 65 | "**/*.js", 66 | ], 67 | extends: [ 68 | "plugin:matrix-org/javascript", 69 | ], 70 | rules: { 71 | "quotes": ["error", "double"], 72 | }, 73 | }, 74 | ], 75 | }; 76 | -------------------------------------------------------------------------------- /.github/workflows/ci-image.yml: -------------------------------------------------------------------------------- 1 | name: Build CI Docker image 2 | 3 | on: workflow_dispatch 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | permissions: 9 | contents: read 10 | packages: write 11 | steps: 12 | - name: Log in to container registry 13 | uses: docker/login-action@v1 14 | with: 15 | registry: ghcr.io 16 | username: ${{ github.actor }} 17 | password: ${{ secrets.GITHUB_TOKEN }} 18 | 19 | - name: Build & push 20 | uses: docker/build-push-action@v2 21 | with: 22 | file: dockerfiles/ci.dockerfile 23 | push: true 24 | tags: ghcr.io/matrix-org/patience-ci:latest 25 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: [main] 7 | 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | 14 | - name: Install Node 15 | uses: actions/setup-node@v2 16 | with: 17 | node-version: 14 18 | cache: npm 19 | 20 | - name: Update npm 21 | run: npm install -g npm@7 22 | 23 | - name: Install modules 24 | run: npm install 25 | 26 | - name: Lint 27 | run: npm run lint -w core 28 | 29 | - name: Type 30 | run: npm run type -w core 31 | 32 | test: 33 | runs-on: ubuntu-latest 34 | container: 35 | image: ghcr.io/matrix-org/patience-ci:latest 36 | env: 37 | DOCKER_BUILDKIT: 1 38 | ports: 39 | - 8448:8448 40 | volumes: 41 | - /var/run/docker.sock:/var/run/docker.sock 42 | steps: 43 | - name: Checkout Patience 44 | uses: actions/checkout@v2 45 | 46 | - name: Change ownership to match container user 47 | run: chown -R root:root . 48 | 49 | - name: Install Node 50 | uses: actions/setup-node@v2 51 | with: 52 | node-version: 14 53 | cache: npm 54 | 55 | - name: Update npm 56 | run: npm install -g npm@7 57 | 58 | - name: Install modules 59 | run: npm install 60 | 61 | - name: Build 62 | run: npm run build -w core 63 | 64 | - name: Checkout Complement 65 | uses: actions/checkout@v2 66 | with: 67 | repository: matrix-org/complement 68 | path: complement 69 | 70 | - name: Install Homerunner 71 | working-directory: ./complement 72 | run: go install ./cmd/homerunner 73 | 74 | - name: Pull homeserver image 75 | run: | 76 | docker pull ghcr.io/matrix-org/complement-dendrite:latest 77 | docker tag ghcr.io/matrix-org/complement-dendrite:latest complement-dendrite 78 | 79 | - name: Make test project directory 80 | working-directory: ${{ runner.temp }} 81 | run: | 82 | mkdir test-project 83 | chown root:root test-project 84 | chmod 777 test-project 85 | 86 | - name: Create test project 87 | working-directory: ${{ runner.temp }}/test-project 88 | run: | 89 | cp $GITHUB_WORKSPACE/examples/package.json . 90 | npm add $GITHUB_WORKSPACE/core --save-dev 91 | cp $GITHUB_WORKSPACE/examples/*.ts . 92 | 93 | - name: Test 94 | working-directory: ${{ runner.temp }}/test-project 95 | env: 96 | DEBUG: harness 97 | run: npx patience 'hello.ts' 98 | -------------------------------------------------------------------------------- /.github/workflows/dendrite-image.yml: -------------------------------------------------------------------------------- 1 | name: Build Dendrite Docker image 2 | 3 | on: workflow_dispatch 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | permissions: 9 | contents: read 10 | packages: write 11 | steps: 12 | - name: Log in to container registry 13 | uses: docker/login-action@v1 14 | with: 15 | registry: ghcr.io 16 | username: ${{ github.actor }} 17 | password: ${{ secrets.GITHUB_TOKEN }} 18 | 19 | - name: Checkout Complement 20 | uses: actions/checkout@v2 21 | with: 22 | repository: matrix-org/complement 23 | path: complement 24 | 25 | - name: Build & push 26 | uses: docker/build-push-action@v2 27 | with: 28 | context: ./complement/dockerfiles 29 | file: ./complement/dockerfiles/Dendrite.Dockerfile 30 | push: true 31 | tags: ghcr.io/matrix-org/complement-dendrite:latest 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .npmrc 3 | build 4 | ca 5 | lib 6 | node_modules 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Patience 2 | 3 | [![CI](https://github.com/matrix-org/patience/actions/workflows/ci.yml/badge.svg)](https://github.com/matrix-org/patience/actions/workflows/ci.yml) 4 | [![Matrix](https://img.shields.io/matrix/matrix-patience:matrix.org?label=%23matrix-patience%3Amatrix.org&logo=matrix)](https://matrix.to/#/#matrix-patience:matrix.org) 5 | 6 | Full stack integration testing for Matrix clients and servers 7 | 8 | ![](example.png) 9 | 10 | ## Features 11 | 12 | * Any permutation of supported clients can be tested together 13 | * Client specifics are hidden by default (but still accessible) so tests can 14 | target many clients without modification 15 | * Tests written in TypeScript 16 | * Report mode gives a quick summary of results in your terminal 17 | * Interactive mode allows you to try out and debug the clients in your browser 18 | * Builds on top of [Complement's `homerunner` API](https://github.com/matrix-org/complement/tree/master/cmd/homerunner) 19 | * Test harness displays all clients together on one page 20 | 21 | ## Supported environments 22 | 23 | Patience aims to support testing different combinations of Matrix clients and 24 | servers in a unified environment. 25 | 26 | Element Web and Hydrogen are the currently supported clients. Anything that fits 27 | in an `iframe` should be easy to add. Tools such as 28 | [Appetize](https://appetize.io/) could be used to add mobile clients. 29 | 30 | Synapse and Dendrite are the currently supported homeservers. 31 | 32 | ## Usage 33 | 34 | Setup is currently a bit manual, as this project is just getting started. If you 35 | have suggestions on how to improve setup, please file an issue. 36 | 37 | To get started with Patience in your project, first collect the following bits 38 | and bobs: 39 | 40 | - Docker 41 | - Go 42 | - Node.js 43 | - `homerunner` from [Complement](https://github.com/matrix-org/complement) 44 | - `go install github.com/matrix-org/complement/cmd/homerunner@latest` 45 | - One or more Complement-ready [homeserver 46 | images](https://github.com/matrix-org/complement#running-against-dendrite) 47 | - Chrome 48 | - We plan to switch to Playwright in the future to support additional browsers 49 | 50 | Create a directory to hold your tests and add Patience: 51 | 52 | `npm add @matrix-org/patience --save-dev` 53 | 54 | Add a test, perhaps by copying [`hello.ts`](./examples/hello.ts). At a minimum, 55 | you should call `orchestrate` to set up servers, clients, and rooms for your 56 | test. Most likely you'll want to actually test something too. 57 | 58 | To run your tests in reporting mode: 59 | 60 | `npx patience '*.ts'` 61 | 62 | You should see: 63 | 64 | ``` 65 | Finished running tests, all tests passed! 🎉 66 | ``` 67 | 68 | To run your tests in interactive mode: 69 | 70 | `npx patience '*.ts' -- --manual` 71 | 72 | This will start a server at `localhost:8000` which you can navigate to in your 73 | browser. Click on one of listed test files to watch the test run. You can 74 | interact with the clients, timeline, etc. The clients remain after for 75 | exploration until you stop the server. 76 | -------------------------------------------------------------------------------- /core/.npmignore: -------------------------------------------------------------------------------- 1 | ca 2 | -------------------------------------------------------------------------------- /core/api/adapters/element-web.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 The Matrix.org Foundation C.I.C. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import type { EventEmitter } from "events"; 18 | 19 | import type { IClientAdapter } from "."; 20 | import type { IClient } from "../../types/client"; 21 | import type { IEventWindow } from "./utils/io"; 22 | import { click, fill, press, query } from "./utils/io"; 23 | import { sleep } from "./utils/time"; 24 | 25 | interface IMatrixClientCreds { 26 | userId: string; 27 | homeserverUrl: string; 28 | identityServerUrl?: string; 29 | deviceId?: string; 30 | accessToken: string; 31 | guest?: boolean; 32 | pickleKey?: string; 33 | freshLogin?: boolean; 34 | } 35 | 36 | interface IMatrixChat { 37 | onUserCompletedLoginFlow(creds: IMatrixClientCreds): Promise; 38 | } 39 | 40 | interface IActionPayload { 41 | action: string; 42 | [key: string]: string; 43 | } 44 | 45 | interface IDispatcher { 46 | register(callback: (action: IActionPayload) => void): string; 47 | unregister(id: string): void; 48 | dispatch(action: IActionPayload, sync?: boolean): void; 49 | } 50 | 51 | interface IMatrixRoom { 52 | roomId: string; 53 | } 54 | 55 | interface IEventContent { 56 | body?: string; 57 | } 58 | 59 | interface IMatrixEvent { 60 | getContent: () => IEventContent; 61 | } 62 | 63 | interface IMatrixClient extends EventEmitter { 64 | getRooms(): IMatrixRoom[]; 65 | } 66 | 67 | interface IFrameWindow extends IEventWindow { 68 | matrixChat: IMatrixChat; 69 | mxDispatcher: IDispatcher; 70 | mxMatrixClientPeg: { 71 | get(): IMatrixClient; 72 | }; 73 | } 74 | 75 | let idb: IDBDatabase; 76 | 77 | async function idbInit(): Promise { 78 | if (!indexedDB) { 79 | throw new Error("IndexedDB not available"); 80 | } 81 | idb = await new Promise((resolve, reject) => { 82 | const request = indexedDB.open("matrix-react-sdk", 1); 83 | request.onerror = reject; 84 | request.onsuccess = (event) => { resolve(request.result); }; 85 | request.onupgradeneeded = (event) => { 86 | const db = request.result; 87 | db.createObjectStore("pickleKey"); 88 | db.createObjectStore("account"); 89 | }; 90 | }); 91 | } 92 | 93 | export async function idbDelete( 94 | table: string, 95 | key: string | string[], 96 | ): Promise { 97 | if (!idb) { 98 | await idbInit(); 99 | } 100 | return new Promise((resolve, reject) => { 101 | const txn = idb.transaction([table], "readwrite"); 102 | txn.onerror = reject; 103 | 104 | const objectStore = txn.objectStore(table); 105 | const request = objectStore.delete(key); 106 | request.onerror = reject; 107 | request.onsuccess = (event) => { resolve(); }; 108 | }); 109 | } 110 | 111 | export default class ElementWebAdapter implements IClientAdapter { 112 | constructor(public model: IClient) { 113 | } 114 | 115 | private get frameWindow(): IFrameWindow { 116 | // @ts-expect-error: Seems hard to type this 117 | return window[this.model.userId].contentWindow; 118 | } 119 | 120 | private get dispatcher(): IDispatcher { 121 | return this.frameWindow.mxDispatcher; 122 | } 123 | 124 | private get matrixClient(): IMatrixClient { 125 | return this.frameWindow.mxMatrixClientPeg.get(); 126 | } 127 | 128 | public async start(): Promise { 129 | this.model.act("start"); 130 | 131 | const { userId, homeserverUrl, accessToken } = this.model; 132 | 133 | // TODO: Would be nice if clients could use snapshotted sessions, rather 134 | // than needing to login for each test. 135 | 136 | // Inject login details via local storage 137 | await this.clearStorage(); 138 | localStorage.setItem("mx_user_id", userId); 139 | localStorage.setItem("mx_hs_url", homeserverUrl); 140 | localStorage.setItem("mx_access_token", accessToken); 141 | 142 | await new Promise(resolve => { 143 | let dispatcherRef: string; 144 | const startupWaitLoop = setInterval(() => { 145 | // Wait until the dispatcher appears 146 | if (!this.dispatcher) { 147 | return; 148 | } 149 | clearInterval(startupWaitLoop); 150 | dispatcherRef = this.dispatcher.register(onAction); 151 | }, 50); 152 | const onAction = ({ action }: IActionPayload) => { 153 | // Wait until the app has processed the stored login 154 | if (action !== "on_logged_in") { 155 | return; 156 | } 157 | this.dispatcher.unregister(dispatcherRef); 158 | resolve(); 159 | }; 160 | // Load the client 161 | this.model.start(); 162 | }); 163 | 164 | // Clear local storage for future use by other sessions 165 | await this.clearStorage(); 166 | 167 | // TODO: For some reason, without this sleep between clients, both clients 168 | // get very strange responses from the homeserver, such as user is not in 169 | // the room, etc. 170 | await sleep(1000); 171 | } 172 | 173 | public async stop(): Promise { 174 | this.model.act("stop"); 175 | this.dispatcher.dispatch({ action: "logout" }, true); 176 | } 177 | 178 | public async waitForRooms(): Promise { 179 | this.model.act("waitForRooms"); 180 | await new Promise(resolve => { 181 | const waitLoop = setInterval(() => { 182 | if (!this.matrixClient.getRooms().length) { 183 | return; 184 | } 185 | clearInterval(waitLoop); 186 | resolve(); 187 | }, 50); 188 | }); 189 | } 190 | 191 | public async viewRoom(roomId?: string): Promise { 192 | this.model.act("viewRoom", roomId); 193 | if (!roomId) { 194 | roomId = this.matrixClient.getRooms()[0].roomId; 195 | } 196 | this.dispatcher.dispatch({ 197 | action: "view_room", 198 | room_id: roomId, 199 | }, true); 200 | } 201 | 202 | public async sendMessage(message: string): Promise { 203 | this.model.act("sendMessage", message); 204 | const composer = await query(this.frameWindow, ".mx_SendMessageComposer"); 205 | click(this.frameWindow, composer); 206 | fill(this.frameWindow, composer, message); 207 | press(this.frameWindow, composer, "Enter"); 208 | await query(this.frameWindow, ".mx_EventTile_last:not(.mx_EventTile_sending)"); 209 | } 210 | 211 | public async waitForMessage(expected?: string): Promise { 212 | // TODO: Maybe we should have generic tracing spans...? 213 | this.model.act("waitForMessage"); 214 | const start = performance.now(); 215 | const message: string = await new Promise(resolve => { 216 | const handler = (event: IMatrixEvent) => { 217 | const body = event.getContent().body; 218 | if (!body) { 219 | return; 220 | } 221 | if (expected && body !== expected) { 222 | return; 223 | } 224 | resolve(body); 225 | }; 226 | this.matrixClient.on("Room.timeline", handler); 227 | }); 228 | this.model.act("waitedForMessage", `${performance.now() - start} ms`); 229 | return message; 230 | } 231 | 232 | private async clearStorage(): Promise { 233 | localStorage.clear(); 234 | await idbDelete("account", "mx_access_token"); 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /core/api/adapters/hydrogen.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 The Matrix.org Foundation C.I.C. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import type { EventEmitter } from "events"; 18 | 19 | import type { IClientAdapter } from "."; 20 | import type { IClient } from "../../types/client"; 21 | import type { IEventWindow } from "./utils/io"; 22 | import { click, fill, press, query } from "./utils/io"; 23 | import { pollFor, waitForFrameDoc } from "./utils/time"; 24 | 25 | interface ISessionItemViewModel extends EventEmitter { 26 | delete: () => Promise; 27 | } 28 | 29 | interface ISessionPickerViewModel extends EventEmitter { 30 | sessions: ISessionItemViewModel[]; 31 | delete: (id: string) => Promise; 32 | } 33 | 34 | interface IWaitForHandle { 35 | promise: Promise; 36 | } 37 | 38 | interface IObservableValue { 39 | get: () => T; 40 | waitFor: (predicate: (value: T) => boolean) => IWaitForHandle; 41 | } 42 | 43 | enum LoadStatus { 44 | NotLoading = "NotLoading", 45 | Login = "Login", 46 | LoginFailed = "LoginFailed", 47 | Loading = "Loading", 48 | SessionSetup = "SessionSetup", 49 | Migrating = "Migrating", 50 | FirstSync = "FirstSync", 51 | Error = "Error", 52 | Ready = "Ready", 53 | } 54 | 55 | interface ISession { 56 | rooms: Map; 57 | } 58 | 59 | interface ISessionContainer { 60 | loadStatus: IObservableValue; 61 | session?: ISession; 62 | } 63 | 64 | interface IListHandler { 65 | onAdd: (index: number, value: T, handler: this) => void; 66 | onUpdate: (index: number, value: T, params: object, handler: this) => void; 67 | } 68 | 69 | interface IObservableList { 70 | subscribe: (handler: IListHandler) => Function; 71 | unsubscribe: (handler: IListHandler) => void; 72 | } 73 | 74 | interface IEventContent { 75 | body?: string; 76 | } 77 | 78 | interface IEventEntry { 79 | content?: IEventContent; 80 | } 81 | 82 | interface ITimeline { 83 | entries: IObservableList; 84 | } 85 | 86 | interface ITimelineViewModel extends EventEmitter { 87 | _timeline: ITimeline; 88 | } 89 | 90 | interface IRoomViewModel extends EventEmitter { 91 | timelineViewModel?: ITimelineViewModel; 92 | } 93 | 94 | interface ISessionViewModel extends EventEmitter { 95 | _sessionContainer: ISessionContainer; 96 | currentRoomViewModel?: IRoomViewModel; 97 | } 98 | 99 | interface IRootViewModel extends EventEmitter { 100 | sessionPickerViewModel?: ISessionPickerViewModel; 101 | sessionViewModel?: ISessionViewModel; 102 | _showPicker: () => Promise; 103 | } 104 | 105 | interface IFrameWindow extends IEventWindow { 106 | __hydrogenViewModel: IRootViewModel; 107 | } 108 | 109 | export default class HydrogenAdapter implements IClientAdapter { 110 | constructor(public model: IClient) { 111 | } 112 | 113 | private get frameWindow(): IFrameWindow { 114 | // @ts-expect-error: Seems hard to type this 115 | return window[this.model.userId].contentWindow; 116 | } 117 | 118 | private get viewModel(): IRootViewModel { 119 | return this.frameWindow.__hydrogenViewModel; 120 | } 121 | 122 | private get timeline(): ITimeline | undefined { 123 | const room = this.viewModel.sessionViewModel?.currentRoomViewModel; 124 | return room?.timelineViewModel?._timeline; 125 | } 126 | 127 | public async start(): Promise { 128 | this.model.act("start"); 129 | 130 | const { userId, homeserverUrl, accessToken } = this.model; 131 | 132 | // Shared session array for possibly multiple Hydrogen clients 133 | let sessions = JSON.parse(localStorage.getItem("hydrogen_sessions_v1") || "[]"); 134 | sessions = sessions.filter(({ id }: {id: string}) => id !== userId); 135 | sessions.push({ 136 | id: userId, 137 | deviceId: null, 138 | userId, 139 | homeServer: homeserverUrl, 140 | homeserver: homeserverUrl, 141 | accessToken, 142 | lastUsed: Date.now(), 143 | }); 144 | localStorage.setItem("hydrogen_sessions_v1", JSON.stringify(sessions)); 145 | 146 | const { frame } = this.model; 147 | if (!frame) { 148 | throw new Error("Client frame has not mounted"); 149 | } 150 | 151 | // Wait for the frame document to be parsed 152 | const frameDoc = await waitForFrameDoc(frame, () => { 153 | this.model.start(); 154 | }); 155 | 156 | // Inject a helper script to disable service workers, as the multiple 157 | // frames on same domain otherwise affect each other via the service 158 | // worker. 159 | const helperScript = frameDoc.createElement("script"); 160 | helperScript.textContent = "(" + function() { 161 | // We can't delete navigator.serviceWorker, so instead we hide it 162 | // with a proxy. 163 | const navigatorProxy = new Proxy({}, { 164 | has: (target, key) => key !== "serviceWorker" && key in target, 165 | }); 166 | Object.defineProperty(window, "navigator", { 167 | value: navigatorProxy, 168 | }); 169 | console.log("Service worker support disabled"); 170 | } + ")()"; 171 | frameDoc.head.prepend(helperScript); 172 | 173 | // Wait for root view model 174 | await pollFor(() => !!this.viewModel); 175 | } 176 | 177 | public async stop(): Promise { 178 | this.model.act("stop"); 179 | await this.viewModel._showPicker(); 180 | await this.viewModel.sessionPickerViewModel?.delete(this.model.userId); 181 | } 182 | 183 | public async waitForRooms(): Promise { 184 | this.model.act("waitForRooms"); 185 | if (!this.viewModel.sessionViewModel) { 186 | await new Promise(resolve => { 187 | const changeHandler = (changed: string) => { 188 | if (changed !== "activeSection") { 189 | return; 190 | } 191 | if (!this.viewModel.sessionViewModel) { 192 | return; 193 | } 194 | this.viewModel.off("change", changeHandler); 195 | resolve(); 196 | }; 197 | this.viewModel.on("change", changeHandler); 198 | }); 199 | } 200 | if (!this.viewModel.sessionViewModel) { 201 | throw new Error("Session view model not ready"); 202 | } 203 | const sessionContainer = this.viewModel.sessionViewModel._sessionContainer; 204 | const { loadStatus } = sessionContainer; 205 | await loadStatus.waitFor(status => status === LoadStatus.Ready).promise; 206 | if (!sessionContainer.session) { 207 | throw new Error("Session missing"); 208 | } 209 | if (sessionContainer.session.rooms.size === 0) { 210 | throw new Error("Rooms failed to load"); 211 | } 212 | } 213 | 214 | public async viewRoom(roomId?: string): Promise { 215 | this.model.act("viewRoom", roomId); 216 | if (!roomId) { 217 | if (!this.viewModel.sessionViewModel) { 218 | throw new Error("Session view model not ready"); 219 | } 220 | const sessionContainer = this.viewModel.sessionViewModel._sessionContainer; 221 | if (!sessionContainer.session) { 222 | throw new Error("Session missing"); 223 | } 224 | roomId = sessionContainer.session.rooms.keys().next().value; 225 | } 226 | const { userId } = this.model; 227 | const { location } = this.frameWindow; 228 | location.hash = `#/session/${userId}/open-room/${roomId}`; 229 | } 230 | 231 | public async sendMessage(message: string): Promise { 232 | this.model.act("sendMessage", message); 233 | const composer = await query(this.frameWindow, ".MessageComposer_input > input"); 234 | click(this.frameWindow, composer); 235 | composer.focus(); 236 | fill(this.frameWindow, composer, message); 237 | press(this.frameWindow, composer, "Enter"); 238 | await query(this.frameWindow, ".Timeline_message:last-child:not(.unsent)"); 239 | } 240 | 241 | public async waitForMessage(expected?: string): Promise { 242 | // TODO: Maybe we should have generic tracing spans...? 243 | this.model.act("waitForMessage"); 244 | const start = performance.now(); 245 | await pollFor(() => !!this.timeline); 246 | const timeline = this.timeline; 247 | if (!timeline) { 248 | throw new Error("Timeline missing"); 249 | } 250 | const message: string = await new Promise(resolve => { 251 | const dispose = timeline.entries.subscribe({ 252 | onAdd(_, event) { 253 | const body = event?.content?.body; 254 | if (!body) { 255 | return; 256 | } 257 | if (expected && body !== expected) { 258 | return; 259 | } 260 | dispose(); 261 | resolve(body); 262 | }, 263 | onUpdate() { }, 264 | }); 265 | }); 266 | this.model.act("waitedForMessage", `${performance.now() - start} ms`); 267 | return message; 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /core/api/adapters/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 The Matrix.org Foundation C.I.C. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import type { IClient } from "../../types/client"; 18 | import { ClientKind } from "../../types/client"; 19 | import ElementWebAdapter from "./element-web"; 20 | import HydrogenAdapter from "./hydrogen"; 21 | 22 | export interface IClientAdapter { 23 | model: IClient; 24 | start(): Promise; 25 | stop(): Promise; 26 | waitForRooms(): Promise; 27 | viewRoom(roomId?: string): Promise; 28 | sendMessage(message: string): Promise; 29 | waitForMessage(expected?: string): Promise; 30 | } 31 | 32 | export default function getAdapterForClient(client: IClient): IClientAdapter { 33 | switch (client.kind) { 34 | case ClientKind.ElementWeb: 35 | return new ElementWebAdapter(client); 36 | case ClientKind.Hydrogen: 37 | return new HydrogenAdapter(client); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /core/api/adapters/utils/io.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 The Matrix.org Foundation C.I.C. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | export interface IEventWindow extends Window { 18 | MouseEvent: { 19 | prototype: MouseEvent; 20 | new(type: string, eventInitDict?: MouseEventInit): MouseEvent; 21 | }; 22 | KeyboardEvent: { 23 | prototype: KeyboardEvent; 24 | new(type: string, eventInitDict?: KeyboardEventInit): KeyboardEvent; 25 | }; 26 | } 27 | 28 | export async function query(win: IEventWindow, selector: string): Promise { 29 | return new Promise(resolve => { 30 | const waitLoop = setInterval(() => { 31 | const element = win.document.querySelector(selector); 32 | if (!element) { 33 | return; 34 | } 35 | clearInterval(waitLoop); 36 | resolve(element); 37 | }, 50); 38 | }); 39 | } 40 | 41 | export function click(win: IEventWindow, element: HTMLElement) { 42 | const rect = element.getBoundingClientRect(); 43 | const MouseEvent = win.MouseEvent; 44 | const event = new MouseEvent("click", { 45 | clientX: rect.left + rect.width / 2, 46 | clientY: rect.top + rect.height / 2, 47 | bubbles: true, 48 | cancelable: true, 49 | }); 50 | element.dispatchEvent(event); 51 | } 52 | 53 | export function fill(win: IEventWindow, element: HTMLElement, message: string) { 54 | element.ownerDocument.execCommand("insertText", false, message); 55 | } 56 | 57 | export function press(win: IEventWindow, element: HTMLElement, key: string) { 58 | const KeyboardEvent = win.KeyboardEvent; 59 | const down = new KeyboardEvent("keydown", { 60 | key, 61 | bubbles: true, 62 | cancelable: true, 63 | }); 64 | element.dispatchEvent(down); 65 | const up = new KeyboardEvent("keyup", { 66 | key, 67 | bubbles: true, 68 | cancelable: true, 69 | }); 70 | element.dispatchEvent(up); 71 | } 72 | -------------------------------------------------------------------------------- /core/api/adapters/utils/time.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 The Matrix.org Foundation C.I.C. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | export function sleep(ms: number): Promise { 18 | return new Promise(resolve => { 19 | setTimeout(resolve, ms); 20 | }); 21 | } 22 | 23 | export async function pollFor(predicate: () => boolean | null): Promise { 24 | if (predicate()) { 25 | return; 26 | } 27 | return new Promise(resolve => { 28 | const pollLoop = setInterval(() => { 29 | if (!predicate()) { 30 | return; 31 | } 32 | clearInterval(pollLoop); 33 | resolve(); 34 | }, 10); 35 | }); 36 | } 37 | 38 | /** 39 | * Wait just long enough for the frame's document to be parsed. At this step, 40 | * it's possible to override platform functions before the frame's own scripts 41 | * run. 42 | */ 43 | export async function waitForFrameDoc( 44 | frame: HTMLIFrameElement, 45 | load: () => void, 46 | ): Promise { 47 | const pollForNavigation = pollFor(() => { 48 | return frame.contentWindow && frame.contentWindow.location.href !== "about:blank"; 49 | }); 50 | load(); 51 | await pollForNavigation; 52 | 53 | await new Promise(resolve => { 54 | const waitForInteractive = () => { 55 | if (frame.contentDocument?.readyState === "loading") { 56 | return; 57 | } 58 | frame.contentDocument?.removeEventListener( 59 | "readystatechange", 60 | waitForInteractive, 61 | ); 62 | resolve(); 63 | }; 64 | frame.contentDocument?.addEventListener( 65 | "readystatechange", 66 | waitForInteractive, 67 | ); 68 | waitForInteractive(); 69 | }); 70 | 71 | const frameDoc = frame.contentDocument; 72 | if (!frameDoc) { 73 | throw new Error("Frame document missing"); 74 | } 75 | 76 | return frameDoc; 77 | } 78 | -------------------------------------------------------------------------------- /core/api/harness.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 The Matrix.org Foundation C.I.C. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import childProcess from "child_process"; 18 | import http from "http"; 19 | import process from "process"; 20 | 21 | import type { Plugin } from "@web/dev-server-core"; 22 | import debug from "debug"; 23 | 24 | import type { IHomerunnerRequest, IHomerunnerResponse } from "./rpc"; 25 | import type { Data } from "./utils"; 26 | import { camelToSnake, fromHomerunner } from "./utils"; 27 | 28 | const log = debug("harness"); 29 | 30 | let instance: Homerunner; 31 | 32 | class Homerunner { 33 | private homerunnerProcess?: childProcess.ChildProcess; 34 | 35 | // Needs `homerunner` from Complement, try running `go install ./cmd/homerunner` 36 | // in a Complement checkout. 37 | private static homerunnerUrl = "http://localhost:54321"; 38 | 39 | static getInstance(): Homerunner { 40 | if (!instance) { 41 | instance = new Homerunner(); 42 | } 43 | return instance; 44 | } 45 | 46 | async deploy(request: IHomerunnerRequest): Promise { 47 | log(request); 48 | await this.start(); 49 | 50 | const deployment = await new Promise((resolve, reject) => { 51 | const httpReq = http.request(`${Homerunner.homerunnerUrl}/create`, { 52 | method: "POST", 53 | }, httpRes => { 54 | httpRes.setEncoding("utf8"); 55 | httpRes.on("data", data => { 56 | if (httpRes.statusCode === 200) { 57 | resolve(JSON.parse(data)); 58 | } else { 59 | reject(new Error(JSON.parse(data).message)); 60 | } 61 | }); 62 | }); 63 | httpReq.write(JSON.stringify({ 64 | base_image_uri: request.baseImageUri || "complement-dendrite", 65 | blueprint_name: camelToSnake(request.blueprintName), 66 | })); 67 | httpReq.end(); 68 | }); 69 | 70 | // Surely there's a more natural way to do this... 71 | return fromHomerunner(deployment as unknown as Data) as unknown as IHomerunnerResponse; 72 | } 73 | 74 | async start() { 75 | if (this.homerunnerProcess) { 76 | return; 77 | } 78 | 79 | try { 80 | this.homerunnerProcess = await childProcess.spawn("homerunner", { 81 | env: Object.assign({ 82 | HOMERUNNER_LIFETIME_MINS: "120", 83 | }, process.env), 84 | }); 85 | process.on("exit", () => { 86 | this.homerunnerProcess?.kill(); 87 | }); 88 | } catch (e) { 89 | console.error("Failed to start Homerunner", e); 90 | throw e; 91 | } 92 | 93 | log("Waiting for Homerunner to listen..."); 94 | await new Promise(resolve => { 95 | this.homerunnerProcess?.stderr?.setEncoding("utf8"); 96 | this.homerunnerProcess?.stderr?.on("data", data => { 97 | log(data); 98 | if (data.includes("Homerunner listening")) { 99 | log("Homerunner listening"); 100 | resolve(); 101 | } 102 | }); 103 | }); 104 | } 105 | } 106 | 107 | module.exports = { 108 | name: "patience", 109 | injectWebSocket: true, 110 | serverStart({ webSockets }) { 111 | webSockets?.on("message", async ({ webSocket, data }) => { 112 | try { 113 | const { type, request } = data; 114 | if (type === "deploy") { 115 | const response = await Homerunner.getInstance().deploy( 116 | request as IHomerunnerRequest, 117 | ); 118 | webSocket.send(JSON.stringify({ 119 | type: "message-response", 120 | id: data.id, 121 | response, 122 | })); 123 | } 124 | } catch (e) { 125 | console.error(e); 126 | let error; 127 | if (e instanceof Error) { 128 | error = e.toString(); 129 | } 130 | webSocket.send(JSON.stringify({ 131 | type: "message-response", 132 | id: data.id, 133 | error, 134 | })); 135 | } 136 | }); 137 | }, 138 | } as Plugin; 139 | -------------------------------------------------------------------------------- /core/api/rpc.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 The Matrix.org Foundation C.I.C. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import type { ClientKind } from "../types/client"; 18 | import type { IClientAdapter } from "./adapters"; 19 | 20 | // See https://github.com/matrix-org/complement/tree/master/internal/b 21 | type PredefinedBlueprint = 22 | "alice" | 23 | "cleanHs" | 24 | "federationOneToOneRoom" | 25 | "federationTwoLocalOneRemote" | 26 | "hsWithApplicationService" | 27 | "oneToOneRoom" | 28 | "perfE2eeRoom" | 29 | "perfManyMessages" | 30 | "perfManyRooms"; 31 | 32 | export interface IHomerunnerRequest { 33 | baseImageUri?: string; 34 | blueprintName: PredefinedBlueprint; 35 | } 36 | 37 | interface IHomerunnerHomeserverInfo { 38 | baseUrl: string; 39 | fedBaseUrl: string; 40 | containerId: string; 41 | accessTokens: { 42 | [userId: string]: string; 43 | }; 44 | } 45 | 46 | export interface IHomerunnerResponse { 47 | homeservers: { 48 | [homeserverId: string]: IHomerunnerHomeserverInfo; 49 | }; 50 | expires: string; 51 | } 52 | 53 | export interface IOrchestrationRequest { 54 | servers: IHomerunnerRequest; 55 | clients: ClientKind | ClientKind[]; 56 | } 57 | 58 | export interface IOrchestrationResponse { 59 | servers: IHomerunnerResponse; 60 | clients: IClientAdapter[]; 61 | } 62 | -------------------------------------------------------------------------------- /core/api/test.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 The Matrix.org Foundation C.I.C. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // This file is imported by tests using the harness to arrange things like 18 | // servers and clients for use in the test. 19 | 20 | import type { IClientSnapshotIn } from "../types/client"; 21 | import getAdapterForClient from "./adapters"; 22 | import type { IHomerunnerResponse, IOrchestrationRequest, IOrchestrationResponse } from "./rpc"; 23 | 24 | export async function orchestrate(request: IOrchestrationRequest): Promise { 25 | // @ts-expect-error: No types available, maybe add some locally 26 | // TODO: Find some way to reference the Web Test Runner port here instead of 27 | // assuming the default value. 28 | const webSocketModule = await import("http://localhost:8000/__web-dev-server__web-socket.js"); 29 | const { sendMessageWaitForResponse } = webSocketModule; 30 | 31 | const servers: IHomerunnerResponse = await sendMessageWaitForResponse({ 32 | type: "deploy", 33 | request: request.servers, 34 | }); 35 | 36 | let clientIndex = 0; 37 | for (const server of Object.values(servers.homeservers)) { 38 | const homeserverUrl = server.baseUrl; 39 | for (const [userId, accessToken] of Object.entries(server.accessTokens)) { 40 | let kind = request.clients; 41 | if (Array.isArray(kind)) { 42 | kind = kind[clientIndex++]; 43 | } 44 | const client: IClientSnapshotIn = { 45 | userId, 46 | homeserverUrl, 47 | accessToken, 48 | kind, 49 | }; 50 | // Add them to the harness UI 51 | window.clientStore.add(client); 52 | } 53 | } 54 | 55 | // TODO: This will return _all_ clients if this function is called more than 56 | // once per test. 57 | // We're using globals via `window` as a way of notifying the test framework 58 | // without importing the framework itself into the test. 59 | const clients = window.clientStore.clients.map(cli => getAdapterForClient(cli)); 60 | window.clients = clients; 61 | 62 | return { 63 | servers, 64 | clients, 65 | }; 66 | } 67 | -------------------------------------------------------------------------------- /core/api/utils.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 The Matrix.org Foundation C.I.C. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | export function camelToSnake(camel: string): string { 18 | return camel.replace(/[A-Z]/g, value => `_${value.toLowerCase()}`); 19 | } 20 | 21 | export type Data = { 22 | [key: string]: string | Data; 23 | }; 24 | 25 | export function fromHomerunner(data: Data): Data { 26 | const result: Data = {}; 27 | for (const key of Object.keys(data)) { 28 | // Transform keys like `BaseURL` to `baseUrl` 29 | const newKey = key 30 | .replace(/^[A-Z]/, 31 | value => value.toLowerCase()) 32 | .replace(/([A-Z])([A-Z]+)/, 33 | (_, first, others) => `${first}${others.toLowerCase()}`); 34 | const newValue = typeof data[key] === "object" ? 35 | fromHomerunner(data[key] as Data) : data[key]; 36 | result[newKey] = newValue; 37 | } 38 | return result; 39 | } 40 | -------------------------------------------------------------------------------- /core/bin/patience.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /* 4 | Copyright 2021 The Matrix.org Foundation C.I.C. 5 | 6 | Licensed under the Apache License, Version 2.0 (the "License"); 7 | you may not use this file except in compliance with the License. 8 | You may obtain a copy of the License at 9 | 10 | http://www.apache.org/licenses/LICENSE-2.0 11 | 12 | Unless required by applicable law or agreed to in writing, software 13 | distributed under the License is distributed on an "AS IS" BASIS, 14 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | See the License for the specific language governing permissions and 16 | limitations under the License. 17 | */ 18 | 19 | const childProcess = require("child_process"); 20 | const path = require("path"); 21 | 22 | const cwd = process.cwd(); 23 | 24 | const testRunnerBinPath = require.resolve("@web/test-runner") 25 | .replace(/test-runner.*$/, "test-runner/dist/bin.js"); 26 | 27 | const testFiles = process.argv[2]; 28 | 29 | try { 30 | const result = childProcess.spawnSync("npx", [ 31 | "ts-node", 32 | "--cwd-mode", 33 | testRunnerBinPath, 34 | // Tests to run, e.g. `*.ts` 35 | // TODO: Handle multiple file paths or provide a nice error message 36 | path.join(cwd, testFiles), 37 | // TODO: Work out the best way to manage parallel orchestration 38 | "--concurrency", 39 | "1", 40 | // Any remaining args are passed through 41 | ...process.argv.slice(3), 42 | ], { 43 | stdio: "inherit", 44 | // Run as if we're in the core directory 45 | cwd: path.dirname(require.resolve("@matrix-org/patience/package.json")), 46 | // Expose test directory for referencing in the Vite config 47 | env: Object.assign({ 48 | PATIENCE_TEST_DIR: cwd, 49 | PATIENCE_TEST_FILES: testFiles, 50 | }, process.env), 51 | }); 52 | process.exitCode = result.status; 53 | } catch (e) { 54 | console.error(e); 55 | process.exitCode = 1; 56 | } 57 | -------------------------------------------------------------------------------- /core/framework/@types/global.d.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 The Matrix.org Foundation C.I.C. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import type { IClientAdapter } from "../../api/adapters"; 18 | import type { IClientStore } from "../stores/client"; 19 | import type { ITimeline } from "../stores/timeline"; 20 | 21 | declare global { 22 | interface Window { 23 | clientStore: IClientStore; 24 | timeline: ITimeline; 25 | clients: IClientAdapter[]; 26 | alice?: IClientAdapter; 27 | bob?: IClientAdapter; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /core/framework/@types/preact.d.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 The Matrix.org Foundation C.I.C. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | declare module "preact/debug"; 18 | -------------------------------------------------------------------------------- /core/framework/components/client-frames.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 The Matrix.org Foundation C.I.C. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import { h } from "preact"; 18 | import type { FunctionComponent } from "preact"; 19 | import { observer } from "mobx-react"; 20 | 21 | import type { IClient, IClientStore } from "../stores/client"; 22 | import getFrameForClient from "./frames"; 23 | 24 | const ClientFrames: FunctionComponent<{ 25 | clientStore: IClientStore; 26 | }> = observer(({ clientStore }) => { 27 | let frames; 28 | if (clientStore.clients.length) { 29 | frames = clientStore.clients.map((client: IClient) => { 30 | return h(getFrameForClient(client), { client }); 31 | }); 32 | } else { 33 | frames =
34 | Waiting for clients... 35 |
; 36 | } 37 | 38 | return
39 | {frames} 40 |
; 41 | }); 42 | 43 | export default ClientFrames; 44 | -------------------------------------------------------------------------------- /core/framework/components/frames/element-web.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 The Matrix.org Foundation C.I.C. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import { h } from "preact"; 18 | import type { FunctionComponent } from "preact"; 19 | import { observer } from "mobx-react"; 20 | 21 | import type { IClient } from "../../stores/client"; 22 | import { ClientFrame } from "./frame"; 23 | 24 | const ElementWebFrame: FunctionComponent<{ client: IClient }> = observer(({ client }) => { 25 | return ; 26 | }); 27 | 28 | export default ElementWebFrame; 29 | -------------------------------------------------------------------------------- /core/framework/components/frames/frame.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 The Matrix.org Foundation C.I.C. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import { h } from "preact"; 18 | import { useCallback } from "preact/hooks"; 19 | import type { FunctionComponent } from "preact"; 20 | import { observer } from "mobx-react"; 21 | 22 | import type { IClient } from "../../stores/client"; 23 | import ZoomToolbar from "../zoom-toolbar"; 24 | 25 | export const ClientFrame: FunctionComponent<{ 26 | client: IClient; 27 | url: string; 28 | }> = observer(({ client, url }) => { 29 | const frameRef = useCallback((frame: HTMLIFrameElement | null) => { 30 | if (frame) { 31 | client.setFrame(frame); 32 | } 33 | }, []); 34 | 35 | const location = client.active ? url : "about:blank"; 36 | 37 | const frameStyles = { 38 | height: `${(100 / client.zoom) * 100}%`, 39 | width: `${(100 / client.zoom) * 100}%`, 40 | transform: `scale(${client.zoom / 100})`, 41 | transformOrigin: "top left", 42 | }; 43 | 44 | return
45 |
46 | {client.name} ({client.userId}) 47 | 48 |
49 |
50 | 55 |
56 |
; 57 | }); 58 | -------------------------------------------------------------------------------- /core/framework/components/frames/hydrogen.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 The Matrix.org Foundation C.I.C. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import { h } from "preact"; 18 | import type { FunctionComponent } from "preact"; 19 | import { observer } from "mobx-react"; 20 | 21 | import type { IClient } from "../../stores/client"; 22 | import { ClientFrame } from "./frame"; 23 | 24 | const HydrogenFrame: FunctionComponent<{ client: IClient }> = observer(({ client }) => { 25 | return ; 28 | }); 29 | 30 | export default HydrogenFrame; 31 | -------------------------------------------------------------------------------- /core/framework/components/frames/index.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 The Matrix.org Foundation C.I.C. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import type { ComponentType } from "preact"; 18 | 19 | import type { IClient } from "../../stores/client"; 20 | import { ClientKind } from "../../../types/client"; 21 | import ElementWebFrame from "./element-web"; 22 | import HydrogenFrame from "./hydrogen"; 23 | 24 | export default function getFrameForClient(client: IClient): ComponentType<{ client: IClient }> { 25 | switch (client.kind) { 26 | case ClientKind.ElementWeb: 27 | return ElementWebFrame; 28 | case ClientKind.Hydrogen: 29 | return HydrogenFrame; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /core/framework/components/timeline.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 The Matrix.org Foundation C.I.C. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import { h } from "preact"; 18 | import type { FunctionComponent } from "preact"; 19 | import { observer } from "mobx-react"; 20 | 21 | import type { IAction, ITimeline } from "../stores/timeline"; 22 | 23 | const Timeline: FunctionComponent<{ 24 | timeline: ITimeline; 25 | }> = observer(({ timeline }) => { 26 | return
27 |
Timeline
28 |
29 | {timeline.actions.map((action: IAction, index: number) => ( 30 |
31 | {action.clientName}: {action.type} {action.value} 32 |
33 | ))} 34 |
35 |
; 36 | }); 37 | 38 | export default Timeline; 39 | -------------------------------------------------------------------------------- /core/framework/components/zoom-toolbar.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 The Matrix.org Foundation C.I.C. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import { h } from "preact"; 18 | import type { FunctionComponent } from "preact"; 19 | import { observer } from "mobx-react"; 20 | import classNames from "classnames"; 21 | 22 | import type { IClient } from "../stores/client"; 23 | 24 | const ZoomToolbar: FunctionComponent<{ client: IClient }> = observer(({ client }) => { 25 | const zoomOptions = [ 26 | { value: 50, label: 50 }, 27 | { value: 66.67, label: 67 }, 28 | { value: 100, label: 100 }, 29 | ]; 30 | 31 | return 32 | {zoomOptions.map(option => { 33 | const selected = client.zoom === option.value; 34 | return ; 38 | })} 39 | ; 40 | }); 41 | 42 | export default ZoomToolbar; 43 | -------------------------------------------------------------------------------- /core/framework/index.css: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 The Matrix.org Foundation C.I.C. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | * { 18 | box-sizing: border-box; 19 | } 20 | 21 | body { 22 | font-family: sans-serif; 23 | } 24 | 25 | .client-frames { 26 | display: flex; 27 | min-height: 400px; 28 | align-items: center; 29 | } 30 | 31 | .client-frames-waiting { 32 | flex: 1; 33 | text-align: center; 34 | } 35 | 36 | .client-frame { 37 | flex: 1; 38 | display: flex; 39 | flex-direction: column; 40 | margin-inline: 4px; 41 | max-width: 50%; 42 | } 43 | 44 | .client-frame:first-child { 45 | margin-inline-start: 0; 46 | } 47 | 48 | .client-frame:last-child { 49 | margin-inline-end: 0; 50 | } 51 | 52 | .client-frame-header { 53 | margin-block-end: 8px; 54 | } 55 | 56 | .client-frame-header > * { 57 | margin-inline-end: 8px; 58 | } 59 | 60 | .client-frame-zoom button { 61 | margin-inline-end: 4px; 62 | } 63 | 64 | .client-frame-zoom button.selected { 65 | font-weight: bold; 66 | } 67 | 68 | .client-frame-frame { 69 | height: 400px; 70 | overflow: hidden; 71 | border: 1px solid; 72 | box-sizing: content-box; 73 | } 74 | 75 | .client-frame-frame > * { 76 | height: 100%; 77 | width: 100%; 78 | border: none; 79 | } 80 | 81 | .timeline { 82 | margin-block-start: 16px; 83 | } 84 | 85 | .timeline-grid { 86 | display: grid; 87 | grid-template-columns: 1fr 1fr; 88 | margin-block-start: 8px; 89 | padding: 8px; 90 | row-gap: 4px; 91 | border: 1px solid; 92 | } 93 | -------------------------------------------------------------------------------- /core/framework/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Patience 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /core/framework/index.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 The Matrix.org Foundation C.I.C. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // TODO: When using Snowpack without a production bundler, this is still 18 | // included in the build output, but it will not actually run. The built-in 19 | // version of `esbuild` could optimise this away, but it needs an upgrade to 20 | // esbuild 0.10.0 to support top-level await. 21 | // https://github.com/snowpackjs/snowpack/issues/3402 22 | if (import.meta.env.MODE === "development") { 23 | await import("preact/debug"); 24 | } 25 | 26 | // TODO: Ideally we could include CSS only via HTML (so that it loads before JS) 27 | // and still have HMR, but that seems to confuse Snowpack at the moment. By 28 | // including it here instead, we get working HMR, which is more important that 29 | // CSS load time during development. 30 | import "./index.css"; 31 | 32 | import type { FunctionComponent } from "preact"; 33 | import { h, Fragment, render } from "preact"; 34 | import { observer } from "mobx-react"; 35 | 36 | import ClientFrames from "./components/client-frames"; 37 | import type { IClientStore } from "./stores/client"; 38 | import clientStore from "./stores/client"; 39 | import type { ITimeline } from "./stores/timeline"; 40 | import timeline from "./stores/timeline"; 41 | import Timeline from "./components/timeline"; 42 | 43 | const App: FunctionComponent<{ 44 | clientStore: IClientStore; 45 | timeline: ITimeline; 46 | }> = observer(({ clientStore, timeline }) => { 47 | return <> 48 | 49 | 50 | ; 51 | }); 52 | 53 | render(, document.body); 57 | 58 | // The test API uses this as a way of notifying the test framework without 59 | // importing the framework itself into the test. 60 | window.clientStore = clientStore; 61 | window.timeline = timeline; 62 | -------------------------------------------------------------------------------- /core/framework/stores/client.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 The Matrix.org Foundation C.I.C. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import type { Instance, SnapshotIn, SnapshotOrInstance } from "mobx-state-tree"; 18 | import { cast, types } from "mobx-state-tree"; 19 | 20 | import { ClientKind } from "../../types/client"; 21 | import timeline from "./timeline"; 22 | 23 | export const Client = types 24 | .model("Client", { 25 | userId: types.identifier, 26 | homeserverUrl: types.string, 27 | accessToken: types.string, 28 | kind: types.enumeration(Object.values(ClientKind)), 29 | active: false, 30 | zoom: 100, 31 | }) 32 | .volatile(self => ({ 33 | frame: null as HTMLIFrameElement | null, 34 | })) 35 | .views(self => ({ 36 | get name(): string { 37 | return self.userId.split("@")[1].split(":")[0] 38 | .replace(/^[a-z]/, value => value.toUpperCase()); 39 | }, 40 | })) 41 | .actions(self => ({ 42 | start() { 43 | self.active = true; 44 | }, 45 | setFrame(frame: HTMLIFrameElement) { 46 | self.frame = frame; 47 | }, 48 | setZoom(value: number) { 49 | self.zoom = value; 50 | }, 51 | act(type: string, value?: string) { 52 | const index = window.clientStore.clients.indexOf(cast(self)); 53 | timeline.add(self.name, index, type, value); 54 | }, 55 | })); 56 | 57 | export interface IClient extends Instance { } 58 | export interface IClientSnapshotIn extends SnapshotIn { } 59 | 60 | const ClientStore = types 61 | .model("ClientStore", { 62 | clients: types.array(Client), 63 | }) 64 | .actions(self => ({ 65 | add(client: SnapshotOrInstance) { 66 | self.clients.push(client); 67 | }, 68 | })); 69 | 70 | export interface IClientStore extends Instance { } 71 | 72 | export default ClientStore.create(); 73 | -------------------------------------------------------------------------------- /core/framework/stores/timeline.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 The Matrix.org Foundation C.I.C. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | import type { Instance, SnapshotIn } from "mobx-state-tree"; 18 | import { types } from "mobx-state-tree"; 19 | 20 | const Action = types 21 | .model("Action", { 22 | // TODO: Figure out references... 23 | // client: types.reference(types.late(() => Client)), 24 | clientName: types.string, 25 | clientIndex: types.number, 26 | type: types.string, 27 | value: types.maybe(types.string), 28 | }) 29 | .actions(self => ({ 30 | })); 31 | 32 | export interface IAction extends Instance { } 33 | export interface IActionSnapshotIn extends SnapshotIn { } 34 | 35 | const Timeline = types 36 | .model("Timeline", { 37 | actions: types.array(Action), 38 | }) 39 | .actions(self => ({ 40 | add(clientName: string, clientIndex: number, type: string, value?: string) { 41 | self.actions.push({ 42 | clientName, 43 | clientIndex, 44 | type, 45 | value, 46 | }); 47 | }, 48 | })); 49 | 50 | export interface ITimeline extends Instance { } 51 | 52 | export default Timeline.create(); 53 | -------------------------------------------------------------------------------- /core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@matrix-org/patience", 3 | "version": "0.0.5", 4 | "description": "Integration test harness for Matrix clients and servers", 5 | "author": "The Matrix.org Foundation C.I.C.", 6 | "license": "Apache-2.0", 7 | "main": "./api/test.ts", 8 | "main_src": "./api/test.ts", 9 | "main_lib": "./lib/api/test.js", 10 | "types_lib": "./lib/api/test.d.ts", 11 | "bin": { 12 | "patience": "./bin/patience.js" 13 | }, 14 | "scripts": { 15 | "start": "vite", 16 | "type": "tsc --noEmit", 17 | "lint": "eslint --ignore-path ../.gitignore .", 18 | "build": "tsc", 19 | "prepublishOnly": "npm run build" 20 | }, 21 | "dependencies": { 22 | "@web/test-runner": "^0.13.17", 23 | "classnames": "^2.3.1", 24 | "debug": "^4.3.2", 25 | "http2-proxy": "^5.0.53", 26 | "koa-proxies": "^0.12.1", 27 | "mobx": "^6.3.3", 28 | "mobx-react": "^7.2.0", 29 | "mobx-state-tree": "^5.0.2", 30 | "preact": "^10.5.14", 31 | "ts-node": "^10.2.1", 32 | "typescript": "^4.4.2", 33 | "vite": "^2.6.1", 34 | "vite-web-test-runner-plugin": "^0.1.0" 35 | }, 36 | "devDependencies": { 37 | "@types/debug": "^4.1.7", 38 | "@typescript-eslint/eslint-plugin": "^4.31.0", 39 | "@typescript-eslint/parser": "^4.31.0", 40 | "eslint": "^7.32.0", 41 | "eslint-config-google": "^0.14.0", 42 | "eslint-plugin-matrix-org": "github:matrix-org/eslint-plugin-matrix-org#main", 43 | "eslint-plugin-react": "^7.25.1" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "target": "es2020", 5 | "outDir": "lib", 6 | "declaration": true, 7 | "moduleResolution": "node", 8 | "jsx": "preserve", 9 | "jsxFactory": "h", 10 | "jsxFragmentFactory": "Fragment", 11 | "strict": true, 12 | "skipLibCheck": true, 13 | "esModuleInterop": true, 14 | "isolatedModules": true, 15 | "useDefineForClassFields": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "resolveJsonModule": true, 18 | "importsNotUsedAsValues": "error", 19 | "types": [ 20 | "vite/client", 21 | ], 22 | }, 23 | "ts-node": { 24 | "transpileOnly": true, 25 | "compilerOptions": { 26 | "module": "commonjs", 27 | }, 28 | }, 29 | } 30 | -------------------------------------------------------------------------------- /core/types/client.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 The Matrix.org Foundation C.I.C. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | export type { IClient, IClientSnapshotIn } from "../framework/stores/client"; 18 | 19 | export enum ClientKind { 20 | ElementWeb = "Element Web", 21 | Hydrogen = "Hydrogen", 22 | } 23 | -------------------------------------------------------------------------------- /core/vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | 3 | import { defineConfig, searchForWorkspaceRoot } from "vite"; 4 | 5 | const { 6 | PATIENCE_TEST_DIR: testDir, 7 | PATIENCE_TEST_FILES: testFiles, 8 | } = process.env; 9 | 10 | const fsAllow = [ 11 | searchForWorkspaceRoot(process.cwd()), 12 | ]; 13 | // Allow test directory if running tests via `patience` command 14 | if (testDir) { 15 | fsAllow.push(testDir); 16 | } 17 | 18 | const optimizeDepsEntries = [ 19 | // Always optimise Patience itself, as Vite default would have done. 20 | "index.html", 21 | ]; 22 | if (testDir && testFiles) { 23 | // Add test files to Vite's optimisation entries to ensure they are 24 | // processed the first time, avoiding Vite reloads that may break the test 25 | // runner. 26 | const rootDir = path.join(process.cwd(), "framework"); 27 | const frameworkRelativeTestDir = path.relative(rootDir, testDir); 28 | optimizeDepsEntries.push(path.join(frameworkRelativeTestDir, testFiles)); 29 | } 30 | 31 | let testPrefix = ""; 32 | if (testDir) { 33 | // Tests are requested by their relative path with upward steps removed 34 | const testRelPath = path.relative(process.cwd(), testDir); 35 | testPrefix = "/" + testRelPath.replace(/\.\.[/\\]/g, ""); 36 | } 37 | 38 | export default defineConfig({ 39 | root: "./framework", 40 | resolve: { 41 | alias: { 42 | "react": "preact/compat", 43 | "react-dom": "preact/compat", 44 | }, 45 | }, 46 | clearScreen: false, 47 | server: { 48 | port: 7284, 49 | strictPort: true, 50 | fs: { 51 | allow: fsAllow, 52 | strict: true, 53 | }, 54 | proxy: { 55 | "/client/element-web": { 56 | target: "https://develop.element.io", 57 | changeOrigin: true, 58 | rewrite: path => path.replace("/client/element-web", ""), 59 | }, 60 | "/client/hydrogen": { 61 | target: "https://hydrogen.element.io", 62 | changeOrigin: true, 63 | rewrite: path => path.replace("/client/hydrogen", ""), 64 | }, 65 | }, 66 | // Only use HMR for development of Patience itself 67 | hmr: !testDir, 68 | }, 69 | build: { 70 | outDir: "./build", 71 | minify: false, 72 | sourcemap: true, 73 | }, 74 | optimizeDeps: { 75 | entries: optimizeDepsEntries, 76 | }, 77 | plugins: [ 78 | { 79 | name: "patience:mount-tests", 80 | enforce: "pre", 81 | resolveId(source) { 82 | // Resolve test directory if running tests via `patience` command 83 | if (testDir) { 84 | if (source.startsWith(testPrefix)) { 85 | return source.replace(testPrefix, testDir); 86 | } 87 | } 88 | return null; 89 | }, 90 | }, 91 | ], 92 | }); 93 | -------------------------------------------------------------------------------- /core/web-test-runner.config.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | 3 | const proxy = require("koa-proxies"); 4 | 5 | const vitePlugin = require("vite-web-test-runner-plugin"); 6 | const { chromeLauncher } = require("@web/test-runner"); 7 | const harnessPlugin = require("./api/harness"); 8 | 9 | process.env.NODE_ENV = "test"; 10 | 11 | // This file is (perhaps confusingly) currently used by _downstream consumers_ 12 | // who are running _their own_ tests via the `patience` command. 13 | 14 | module.exports = { 15 | middleware: [ 16 | proxy("/client/element-web", { 17 | target: "https://develop.element.io", 18 | changeOrigin: true, 19 | rewrite: path => path.replace("/client/element-web", ""), 20 | }), 21 | proxy("/client/hydrogen", { 22 | target: "https://hydrogen.element.io", 23 | changeOrigin: true, 24 | rewrite: path => path.replace("/client/hydrogen", ""), 25 | }), 26 | ], 27 | plugins: [ 28 | vitePlugin(), 29 | harnessPlugin, 30 | ], 31 | testRunnerHtml: testFramework => { 32 | let html = fs.readFileSync("./framework/index.html", "utf8"); 33 | html = html.replace("${testFramework}", testFramework); 34 | html = html.replace("ignore", "module"); 35 | return html; 36 | }, 37 | testFramework: { 38 | config: { 39 | timeout: 30000, 40 | }, 41 | }, 42 | browsers: [ 43 | chromeLauncher({ 44 | launchOptions: { 45 | args: process.env.CI ? [ 46 | "--no-sandbox", 47 | ] : [], 48 | }, 49 | }), 50 | ], 51 | }; 52 | -------------------------------------------------------------------------------- /dockerfiles/ci.dockerfile: -------------------------------------------------------------------------------- 1 | # This Dockerfile is intended for Patience's own CI. 2 | # It may change at any time. 3 | 4 | FROM golang:1.16-buster 5 | 6 | RUN echo "deb http://deb.debian.org/debian buster-backports main" > /etc/apt/sources.list.d/patience.list && apt-get update && apt-get install -y libolm3 libolm-dev/buster-backports chromium 7 | RUN curl -fsSL https://get.docker.com -o get-docker.sh && sh get-docker.sh 8 | 9 | # Complement uses this for cert storage 10 | VOLUME [ "/ca" ] 11 | -------------------------------------------------------------------------------- /example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matrix-org/patience/63dd20385c2fcedafcd1da7ad401e874c0ef96e7/example.png -------------------------------------------------------------------------------- /examples/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | mocha: true, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | This directory showcases how a separate project can use Patience as a Matrix 2 | integration testing framework. 3 | 4 | * [hello.ts](./hello.ts): Two local clients sending messages 5 | * [federated.ts](./federated.ts): Two federated clients sending messages 6 | -------------------------------------------------------------------------------- /examples/federated.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 The Matrix.org Foundation C.I.C. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | /* eslint-disable @typescript-eslint/no-invalid-this */ 18 | 19 | import { expect } from "chai"; 20 | 21 | import { orchestrate } from "@matrix-org/patience"; 22 | import { ClientKind } from "@matrix-org/patience/types/client"; 23 | 24 | const { clients } = await orchestrate({ 25 | servers: { 26 | blueprintName: "federationOneToOneRoom", 27 | }, 28 | clients: ClientKind.ElementWeb, 29 | }); 30 | const alice = window.alice = clients[0]; 31 | const bob = window.bob = clients[1]; 32 | 33 | after(async () => { 34 | await alice.stop(); 35 | await bob.stop(); 36 | }); 37 | 38 | it("logs into both clients", async function() { 39 | await alice.start(); 40 | await bob.start(); 41 | }); 42 | 43 | it("has a conversation", async function() { 44 | await alice.waitForRooms(); 45 | await bob.waitForRooms(); 46 | await alice.viewRoom(); 47 | await bob.viewRoom(); 48 | 49 | const bobWaitsForMessage = bob.waitForMessage("Hi Bob!"); 50 | await alice.sendMessage("Hi Bob!"); 51 | expect(await bobWaitsForMessage).to.equal("Hi Bob!"); 52 | await bob.sendMessage("Hello Alice!"); 53 | }); 54 | -------------------------------------------------------------------------------- /examples/hello.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 The Matrix.org Foundation C.I.C. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | /* eslint-disable @typescript-eslint/no-invalid-this */ 18 | 19 | import { expect } from "chai"; 20 | 21 | import { orchestrate } from "@matrix-org/patience"; 22 | import { ClientKind } from "@matrix-org/patience/types/client"; 23 | 24 | const { servers, clients } = await orchestrate({ 25 | servers: { 26 | blueprintName: "oneToOneRoom", 27 | }, 28 | clients: ClientKind.ElementWeb, 29 | }); 30 | const alice = window.alice = clients[0]; 31 | const bob = window.bob = clients[1]; 32 | 33 | after(async () => { 34 | await alice.stop(); 35 | await bob.stop(); 36 | }); 37 | 38 | it("displays 2 client frames", async function() { 39 | expect(Object.keys(servers.homeservers.hs1.accessTokens).length).to.equal(2); 40 | expect(clients.length).to.equal(2); 41 | expect(window.frames.length).to.equal(2); 42 | }); 43 | 44 | it("logs into both clients", async function() { 45 | await alice.start(); 46 | await bob.start(); 47 | }); 48 | 49 | it("has a conversation", async function() { 50 | await alice.waitForRooms(); 51 | await bob.waitForRooms(); 52 | await alice.viewRoom(); 53 | await bob.viewRoom(); 54 | 55 | const bobWaitsForMessage = bob.waitForMessage("Hi Bob!"); 56 | await alice.sendMessage("Hi Bob!"); 57 | expect(await bobWaitsForMessage).to.equal("Hi Bob!"); 58 | await bob.sendMessage("Hello Alice!"); 59 | }); 60 | -------------------------------------------------------------------------------- /examples/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@matrix-org/patience-examples", 3 | "version": "0.0.0", 4 | "author": "The Matrix.org Foundation C.I.C.", 5 | "license": "Apache-2.0", 6 | "scripts": { 7 | "test": "patience '*.ts'" 8 | }, 9 | "devDependencies": { 10 | "@matrix-org/patience": "0.0.4", 11 | "@types/chai": "^4.2.21", 12 | "@types/mocha": "^8.2.3", 13 | "chai": "^4.3.4" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /examples/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "target": "es2020", 5 | "moduleResolution": "node", 6 | "noEmit": true, 7 | "strict": true, 8 | "skipLibCheck": true, 9 | "esModuleInterop": true, 10 | "isolatedModules": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "resolveJsonModule": true, 13 | "importsNotUsedAsValues": "error", 14 | }, 15 | } 16 | -------------------------------------------------------------------------------- /examples/utils/sleep.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 The Matrix.org Foundation C.I.C. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | export default function(ms: number): Promise { 18 | return new Promise(resolve => { 19 | setTimeout(resolve, ms); 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "workspaces": [ 4 | "core", 5 | "examples" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /scripts/release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | npm version patch -w core 6 | 7 | pushd core 8 | version=$(cat package.json | jq -r '.version') 9 | # For the published version of the package, we copy the `main_lib` and 10 | # `types_lib` fields to `main` and `types` (if they exist). This small bit of 11 | # gymnastics allows us to use the TypeScript source directly for development 12 | # without needing to build before linting or testing. 13 | for i in main types 14 | do 15 | lib_value=$(jq -r ".${i}_lib" package.json) 16 | if [ "$lib_value" != "null" ]; then 17 | jq ".$i = .${i}_lib" --indent 4 package.json > package.json.new && mv package.json.new package.json 18 | fi 19 | done 20 | git commit package.json -m "patience ${version}" 21 | popd 22 | 23 | npm publish -w core 24 | 25 | pushd core 26 | # When merging to develop, we need revert the `main` and `types` fields if we 27 | # adjusted them previously. 28 | for i in main types 29 | do 30 | # If a `lib` value is present, it means we adjusted the field earlier at 31 | # publish time, so we should revert it now. 32 | if [ "$(jq -r ".${i}_lib" package.json)" != "null" ]; then 33 | # If there's a `src` value, use that, otherwise delete. 34 | # This is used to delete the `types` field and reset `main` back to the 35 | # TypeScript source. 36 | src_value=$(jq -r ".${i}_src" package.json) 37 | if [ "$src_value" != "null" ]; then 38 | jq ".$i = .${i}_src" --indent 4 package.json > package.json.new && mv package.json.new package.json 39 | else 40 | jq "del(.$i)" --indent 4 package.json > package.json.new && mv package.json.new package.json 41 | fi 42 | fi 43 | done 44 | git commit package.json -m "Resetting package fields for development" 45 | popd 46 | 47 | npm add "@matrix-org/patience@${version}" --save-exact -w examples 48 | git commit package-lock.json examples/package.json -m "Upgrade to patience ${version}" 49 | --------------------------------------------------------------------------------