@ynput/ayon-addon-dev 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +139 -0
- package/LICENSE +105 -0
- package/dist/browse.d.ts +59 -0
- package/dist/browse.js +156 -0
- package/dist/check.d.ts +30 -0
- package/dist/check.js +117 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +249 -0
- package/dist/eslint.d.ts +13 -0
- package/dist/eslint.js +47 -0
- package/dist/project.d.ts +28 -0
- package/dist/project.js +97 -0
- package/dist/rest.d.ts +47 -0
- package/dist/rest.js +181 -0
- package/dist/shell/index.js +1635 -0
- package/dist/shell/style.css +416 -0
- package/dist/standalone.d.ts +12 -0
- package/dist/standalone.js +176 -0
- package/dist/vite.d.ts +31 -0
- package/dist/vite.js +147 -0
- package/package.json +63 -4
- package/README.md +0 -3
package/AGENTS.md
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# @ynput/ayon-addon-dev
|
|
2
|
+
|
|
3
|
+
Tooling for AYON addon frontends. The API itself (slots, `useSlot`, the manifest) is
|
|
4
|
+
`@ynput/ayon-addon`: read `node_modules/@ynput/ayon-addon/AGENTS.md` first.
|
|
5
|
+
|
|
6
|
+
## Vite: `ayonAddon()`
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
// vite.config.ts
|
|
10
|
+
import react from '@vitejs/plugin-react'
|
|
11
|
+
import { ayonAddon } from '@ynput/ayon-addon-dev/vite'
|
|
12
|
+
|
|
13
|
+
export default defineConfig({ plugins: [react(), ayonAddon({ name: 'my_addon' })] })
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
- `name` is the federation remote: the key in the server's `frontend_modules`
|
|
17
|
+
(`{"my_addon": ["manifest"]}`), and the build goes to `dist/modules/<name>`.
|
|
18
|
+
- It exposes `./manifest` (`src/manifest.ts`, or `manifest`), plus `exposes` if the addon keeps
|
|
19
|
+
keyword modules for older AYON versions.
|
|
20
|
+
- It shares everything AYON provides (React, the store, the router, toasts, every
|
|
21
|
+
`@ynput/ayon-frontend-shared` entry) as singletons, and `@ynput/ayon-addon` with `import: false`:
|
|
22
|
+
the addon never bundles a copy of it.
|
|
23
|
+
|
|
24
|
+
## CLI: `ayon-addon`
|
|
25
|
+
|
|
26
|
+
Run in the addon's frontend folder. Every command takes `--json` for scripts and agents.
|
|
27
|
+
|
|
28
|
+
| Command | What |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `ayon-addon dev [--port 5174]` | The dev shell (below): the addon in AYON's surfaces, without AYON, with hot reload |
|
|
31
|
+
| `ayon-addon dev --ayon [--port 5173]` | Builds on every change and serves the build with CORS. Prints the AYON URL that loads it: `<server>/?addonDev=<addon>@http://localhost:5173` |
|
|
32
|
+
| `ayon-addon slots` | The slots of the installed `@ynput/ayon-addon`: helper, where, when the code loads |
|
|
33
|
+
| `ayon-addon check [--no-types]` | The manifest as AYON reads it (slots, ids, fields, pieces), that each piece's file exists, and `tsc` |
|
|
34
|
+
| `ayon-addon open <slot> <id> [--project P] [--entities type:id,...] [--page overview\|products\|lists] [--run] [--screenshot file] [--save-fixture name]` | One contribution in the dev shell in a headless browser, against the server. Reports error boxes, console errors, toasts, the context it got, the clipboard after `--run` (menu items). Exit code 1 when it failed |
|
|
35
|
+
| `ayon-addon preview <slot> <id> --fixture name [--run] [--prune] [--screenshot file]` | The same from `dev/fixtures/<name>.json`, without a server. `--prune` drops the answers it didn't use |
|
|
36
|
+
| `ayon-addon generate-rest [--schema file] [--hooks]` | The addon's server endpoints as RTK Query endpoints in AYON's api (below) |
|
|
37
|
+
|
|
38
|
+
`open` and `preview` start the dev shell themselves, or use a running one with `--url
|
|
39
|
+
http://localhost:5174`. They need playwright (`yarn add -D playwright`, `npx playwright install
|
|
40
|
+
chromium`); in the AYON dev workbench the ayon-frontend checkout's copy is used.
|
|
41
|
+
|
|
42
|
+
## Developing inside AYON (`dev --ayon`)
|
|
43
|
+
|
|
44
|
+
1. `ayon-addon dev --ayon` in the addon.
|
|
45
|
+
2. Open the URL it prints. The page loads your addon from the dev server instead of the installed
|
|
46
|
+
version (also when it isn't installed) and shows a banner. It reloads after every rebuild.
|
|
47
|
+
3. `?addonDev=off`, or the banner's close button, goes back to the installed addon.
|
|
48
|
+
|
|
49
|
+
Only users in developer mode (and service users, for agents) can do this, and only from
|
|
50
|
+
`localhost`.
|
|
51
|
+
|
|
52
|
+
## Developing without AYON: the dev shell (`dev`, the default)
|
|
53
|
+
|
|
54
|
+
`ayon-addon dev` serves the addon with Vite's dev server (hot reload) inside a dev shell instead
|
|
55
|
+
of AYON:
|
|
56
|
+
|
|
57
|
+
- **Real AYON surfaces** from `@ynput/ayon-frontend-shared`: the details panel, the slicer, the
|
|
58
|
+
Customize panel, and the addon columns' cells and `fetchValues`, in AYON's providers (store, api,
|
|
59
|
+
contexts). AYON's project list table, with only the addon's items in its menus (AYON's own,
|
|
60
|
+
such as open, pin and folders, need the app). Plain stand-ins for what lives in the AYON app: the header and the
|
|
61
|
+
table around the columns (its rows are the picked entities, its right-click menu has the
|
|
62
|
+
`entityMenuItem` items, and once clicked it takes the `table.shortcut` keys). Pages render alone, without AYON's page tabs.
|
|
63
|
+
- **Developer panel**, in two resizable halves that scroll on their own: the manifest's
|
|
64
|
+
contributions grouped by the area of AYON they show in (app header, studio, project with its
|
|
65
|
+
slicer and tables, details panel), then by slot, with the ones AYON would skip and why; below,
|
|
66
|
+
the context of the one on stage, the only place it is changed: a project switcher, the studio's
|
|
67
|
+
project selection (also what a studio page's project list shows), an entity picker (AYON's) for
|
|
68
|
+
the slots about entities, the table page, *Run* for menu items, a few lines of the context the
|
|
69
|
+
contribution gets (click for all of it), and the preview fixtures.
|
|
70
|
+
- Above the stage, a caption names the contribution, where AYON shows it and its slot.
|
|
71
|
+
- The selection is in the URL
|
|
72
|
+
(`?slot=…&contribution=…&ctxProject=…&ctxProjects=a,b&ctxEntities=task:<id>,…`).
|
|
73
|
+
- Requests to `/api`, `/graphql`, `/ws` go through the dev server to the AYON server, with the API
|
|
74
|
+
key when there is one (no login page); without a key the page shows AYON's login.
|
|
75
|
+
- It needs a `@ynput/ayon-frontend-shared` build with the addon SDK (until one is released:
|
|
76
|
+
`yarn copy-shared <ayon-frontend>` after `yarn build:shared` there).
|
|
77
|
+
|
|
78
|
+
### Preview: fixtures instead of a server
|
|
79
|
+
|
|
80
|
+
`?preview=<name>` (or `ayon-addon preview`) renders one contribution with the context and server
|
|
81
|
+
answers of `dev/fixtures/<name>.json`, and no server:
|
|
82
|
+
|
|
83
|
+
```json
|
|
84
|
+
{
|
|
85
|
+
"slot": "detailsPanel.tab",
|
|
86
|
+
"id": "comments",
|
|
87
|
+
"context": { "projectName": "demo", "entityType": "task", "entities": [{ "id": "…", "entityType": "task", "projectName": "demo" }], "scope": "addon-dev" },
|
|
88
|
+
"responses": { "graphql GetComments": { "status": 200, "body": { "data": { … } } } }
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
- Answers are keyed `GET /api/<path>?<query>` or `graphql <OperationName>` (the variables don't
|
|
93
|
+
count). A request without an answer gets a 404 and fails the preview (`not in the fixture`).
|
|
94
|
+
- The user, the site info, the project list and the cloud status are answered by the shell (an
|
|
95
|
+
admin "preview" user): fixtures leave them out.
|
|
96
|
+
- Record one from a live server: *Save* in the developer panel, or `ayon-addon open … --save-fixture
|
|
97
|
+
<name>`; then `ayon-addon preview … --fixture <name> --prune`. A recording holds the server's real
|
|
98
|
+
data: check it before committing it (project names, users, comment text).
|
|
99
|
+
|
|
100
|
+
## Server endpoints: `ayon-addon generate-rest`
|
|
101
|
+
|
|
102
|
+
Addons that add their own server endpoints generate the frontend side from the addon's OpenAPI
|
|
103
|
+
schema (needs `@rtk-query/codegen-openapi` as a dev dependency):
|
|
104
|
+
|
|
105
|
+
1. Downloads `/api/addons/<name>/<version>/openapi.json` (name and version from `package.py`) from
|
|
106
|
+
a server with that version installed, to `gen/openapi.json` (gitignore it). AYON's own
|
|
107
|
+
`/openapi.json` has no addon routes. `--schema <file>` uses a saved schema instead.
|
|
108
|
+
2. Generates `src/api/generated/<name>.ts`: the endpoints injected into AYON's api (one cache with
|
|
109
|
+
AYON's), without hooks. Operation ids are prefixed with the addon name, so endpoints can't
|
|
110
|
+
clash with AYON's or another addon's (`timetrackerStartSession`). URLs use the default export
|
|
111
|
+
of `src/addonVersion.ts`, so they call the running version.
|
|
112
|
+
3. **Never edit the generated file.** Tags, `transformResponse` and optimistic updates go in
|
|
113
|
+
`src/api/<name>.ts`, which enhances the endpoints and exports their hooks. The first run creates
|
|
114
|
+
it; later runs list endpoints whose hooks it doesn't export and, in a terminal, offer to add
|
|
115
|
+
them in a block of their own (`--hooks`: without asking). Hooks of removed endpoints are only
|
|
116
|
+
reported.
|
|
117
|
+
|
|
118
|
+
Options in `ayonAddon({ rest: { ... } })`: `queries` (POST endpoints that only read, generated as
|
|
119
|
+
queries), `generated`, `api`, `versionModule`, `schema` (paths).
|
|
120
|
+
|
|
121
|
+
## Signing in
|
|
122
|
+
|
|
123
|
+
- People: the AYON login.
|
|
124
|
+
- Agents and CI: `AYON_API_KEY` (a service user's key), sent as `x-api-key`. In the AYON dev
|
|
125
|
+
workbench the tools find the local stack and its key (`data/local-api-key`) on their own.
|
|
126
|
+
- `AYON_SERVER_URL` points the tools at another server (default `http://localhost:5000`, or the
|
|
127
|
+
workbench's local stack).
|
|
128
|
+
|
|
129
|
+
## Lint preset
|
|
130
|
+
|
|
131
|
+
```js
|
|
132
|
+
// eslint.config.js
|
|
133
|
+
import { ayonAddonLint } from '@ynput/ayon-addon-dev/eslint'
|
|
134
|
+
export default defineConfig([..., ...ayonAddonLint])
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Flags imports that bypass AYON's copies: `react-router-dom`, `react-router` and `react-toastify`
|
|
138
|
+
(use `@ynput/ayon-addon`), `@ynput/ayon-addon/host`, and a manifest importing
|
|
139
|
+
`@ynput/ayon-addon` instead of `@ynput/ayon-addon/manifest`.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Functional Source License, Version 1.1, ALv2 Future License
|
|
2
|
+
|
|
3
|
+
## Abbreviation
|
|
4
|
+
|
|
5
|
+
FSL-1.1-ALv2
|
|
6
|
+
|
|
7
|
+
## Notice
|
|
8
|
+
|
|
9
|
+
Copyright 2025 Ynput s.r.o.
|
|
10
|
+
|
|
11
|
+
## Terms and Conditions
|
|
12
|
+
|
|
13
|
+
### Licensor ("We")
|
|
14
|
+
|
|
15
|
+
The party offering the Software under these Terms and Conditions.
|
|
16
|
+
|
|
17
|
+
### The Software
|
|
18
|
+
|
|
19
|
+
The "Software" is each version of the software that we make available under
|
|
20
|
+
these Terms and Conditions, as indicated by our inclusion of these Terms and
|
|
21
|
+
Conditions with the Software.
|
|
22
|
+
|
|
23
|
+
### License Grant
|
|
24
|
+
|
|
25
|
+
Subject to your compliance with this License Grant and the Patents,
|
|
26
|
+
Redistribution and Trademark clauses below, we hereby grant you the right to
|
|
27
|
+
use, copy, modify, create derivative works, publicly perform, publicly display
|
|
28
|
+
and redistribute the Software for any Permitted Purpose identified below.
|
|
29
|
+
|
|
30
|
+
### Permitted Purpose
|
|
31
|
+
|
|
32
|
+
A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
|
|
33
|
+
means making the Software available to others in a commercial product or
|
|
34
|
+
service that:
|
|
35
|
+
|
|
36
|
+
1. substitutes for the Software;
|
|
37
|
+
|
|
38
|
+
2. substitutes for any other product or service we offer using the Software
|
|
39
|
+
that exists as of the date we make the Software available; or
|
|
40
|
+
|
|
41
|
+
3. offers the same or substantially similar functionality as the Software.
|
|
42
|
+
|
|
43
|
+
Permitted Purposes specifically include using the Software:
|
|
44
|
+
|
|
45
|
+
1. for your internal use and access;
|
|
46
|
+
|
|
47
|
+
2. for non-commercial education;
|
|
48
|
+
|
|
49
|
+
3. for non-commercial research; and
|
|
50
|
+
|
|
51
|
+
4. in connection with professional services that you provide to a licensee
|
|
52
|
+
using the Software in accordance with these Terms and Conditions.
|
|
53
|
+
|
|
54
|
+
### Patents
|
|
55
|
+
|
|
56
|
+
To the extent your use for a Permitted Purpose would necessarily infringe our
|
|
57
|
+
patents, the license grant above includes a license under our patents. If you
|
|
58
|
+
make a claim against any party that the Software infringes or contributes to
|
|
59
|
+
the infringement of any patent, then your patent license to the Software ends
|
|
60
|
+
immediately.
|
|
61
|
+
|
|
62
|
+
### Redistribution
|
|
63
|
+
|
|
64
|
+
The Terms and Conditions apply to all copies, modifications and derivatives of
|
|
65
|
+
the Software.
|
|
66
|
+
|
|
67
|
+
If you redistribute any copies, modifications or derivatives of the Software,
|
|
68
|
+
you must include a copy of or a link to these Terms and Conditions and not
|
|
69
|
+
remove any copyright notices provided in or with the Software.
|
|
70
|
+
|
|
71
|
+
### Disclaimer
|
|
72
|
+
|
|
73
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
|
|
74
|
+
IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
|
|
75
|
+
PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
|
|
76
|
+
|
|
77
|
+
IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
|
|
78
|
+
SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
|
|
79
|
+
EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
|
|
80
|
+
|
|
81
|
+
### Trademarks
|
|
82
|
+
|
|
83
|
+
Except for displaying the License Details and identifying us as the origin of
|
|
84
|
+
the Software, you have no right under these Terms and Conditions to use our
|
|
85
|
+
trademarks, trade names, service marks or product names.
|
|
86
|
+
|
|
87
|
+
## Grant of Future License
|
|
88
|
+
|
|
89
|
+
We hereby irrevocably grant you an additional license to use the Software under
|
|
90
|
+
the Apache License, Version 2.0 that is effective on the second anniversary of
|
|
91
|
+
the date we make the Software available. On or after that date, you may use the
|
|
92
|
+
Software under the Apache License, Version 2.0, in which case the following
|
|
93
|
+
will apply:
|
|
94
|
+
|
|
95
|
+
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
|
|
96
|
+
this file except in compliance with the License.
|
|
97
|
+
|
|
98
|
+
You may obtain a copy of the License at
|
|
99
|
+
|
|
100
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
101
|
+
|
|
102
|
+
Unless required by applicable law or agreed to in writing, software distributed
|
|
103
|
+
under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
|
|
104
|
+
CONDITIONS OF ANY KIND, either express or implied. See the License for the
|
|
105
|
+
specific language governing permissions and limitations under the License.
|
package/dist/browse.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export interface BrowseOptions {
|
|
2
|
+
root: string;
|
|
3
|
+
slot: string;
|
|
4
|
+
id: string;
|
|
5
|
+
/** preview mode: the fixture's name (dev/fixtures/<name>.json) */
|
|
6
|
+
fixture?: string;
|
|
7
|
+
project?: string;
|
|
8
|
+
/** `type:id,type:id` */
|
|
9
|
+
entities?: string;
|
|
10
|
+
page?: string;
|
|
11
|
+
/** a running `ayon-addon dev` */
|
|
12
|
+
url?: string;
|
|
13
|
+
screenshot?: string;
|
|
14
|
+
/** run the menu item (`run` piece) and report what it did */
|
|
15
|
+
run?: boolean;
|
|
16
|
+
/** live mode: save what the contribution loaded as dev/fixtures/<name>.json */
|
|
17
|
+
saveFixture?: string;
|
|
18
|
+
/** preview: keep only the fixture answers the page used */
|
|
19
|
+
prune?: boolean;
|
|
20
|
+
timeout?: number;
|
|
21
|
+
}
|
|
22
|
+
export interface BrowseResult {
|
|
23
|
+
ok: boolean;
|
|
24
|
+
mode: 'live' | 'preview';
|
|
25
|
+
url: string;
|
|
26
|
+
slot: string;
|
|
27
|
+
id: string;
|
|
28
|
+
/** the slot context the contribution got (functions and elements left out) */
|
|
29
|
+
context?: Record<string, unknown>;
|
|
30
|
+
/** contributions that crashed or failed to load (AYON's error boxes) */
|
|
31
|
+
contributionErrors: string[];
|
|
32
|
+
/** console errors and uncaught exceptions */
|
|
33
|
+
errors: string[];
|
|
34
|
+
warnings: string[];
|
|
35
|
+
toasts: string[];
|
|
36
|
+
/** with --run: what the item put on the clipboard */
|
|
37
|
+
clipboard?: string;
|
|
38
|
+
/** preview: requests the fixture has no answer for */
|
|
39
|
+
unmatched?: string[];
|
|
40
|
+
screenshot?: string;
|
|
41
|
+
savedFixture?: string;
|
|
42
|
+
/** with --prune: the answers removed from the fixture */
|
|
43
|
+
pruned?: number;
|
|
44
|
+
}
|
|
45
|
+
export declare const browse: (options: BrowseOptions) => Promise<BrowseResult>;
|
|
46
|
+
export declare const formatBrowse: (result: BrowseResult) => string;
|
|
47
|
+
declare global {
|
|
48
|
+
interface Window {
|
|
49
|
+
__AYON_ADDON_SHELL__?: {
|
|
50
|
+
ready: boolean;
|
|
51
|
+
unmatched: string[];
|
|
52
|
+
used: string[];
|
|
53
|
+
fixture: () => {
|
|
54
|
+
context: Record<string, unknown>;
|
|
55
|
+
} | undefined;
|
|
56
|
+
run: () => Promise<void>;
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
}
|
package/dist/browse.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// `ayon-addon open | preview`: the dev shell in a headless browser, for agents and scripts.
|
|
2
|
+
// Starts the standalone dev server unless --url points at a running one, waits until the
|
|
3
|
+
// contribution rendered, and reports what happened (errors, toasts, the context it got).
|
|
4
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
5
|
+
import { createRequire } from 'module';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { pathToFileURL } from 'url';
|
|
8
|
+
import { findConnection, importFromProject } from './project.js';
|
|
9
|
+
import { STANDALONE_ENV } from './standalone.js';
|
|
10
|
+
// the addon's playwright, else the one next to this package (the ayon-frontend checkout)
|
|
11
|
+
const loadPlaywright = async (root) => {
|
|
12
|
+
// a CommonJS package: its API is the default export
|
|
13
|
+
const api = (module) => module.default ?? module;
|
|
14
|
+
try {
|
|
15
|
+
return api(await importFromProject(root, 'playwright'));
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
try {
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
return api(await import(pathToFileURL(require.resolve('playwright')).href));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
throw new Error('playwright is not installed: yarn add -D playwright && npx playwright install chromium');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
const startServer = async (root) => {
|
|
28
|
+
const vite = await importFromProject(root, 'vite');
|
|
29
|
+
process.env[STANDALONE_ENV] = '1';
|
|
30
|
+
const server = await vite.createServer({
|
|
31
|
+
root,
|
|
32
|
+
logLevel: 'error',
|
|
33
|
+
server: { port: 5180, strictPort: false },
|
|
34
|
+
});
|
|
35
|
+
await server.listen();
|
|
36
|
+
return server;
|
|
37
|
+
};
|
|
38
|
+
export const browse = async (options) => {
|
|
39
|
+
const mode = options.fixture ? 'preview' : 'live';
|
|
40
|
+
if (mode === 'live' && !options.url && !findConnection(options.root).apiKey) {
|
|
41
|
+
throw new Error('no API key for the headless browser: set AYON_API_KEY (and AYON_SERVER_URL)');
|
|
42
|
+
}
|
|
43
|
+
const server = options.url ? undefined : await startServer(options.root);
|
|
44
|
+
const base = options.url || server.resolvedUrls.local[0];
|
|
45
|
+
// the shell's URL parameters (shell/src/params.ts)
|
|
46
|
+
const params = new URLSearchParams({ slot: options.slot, contribution: options.id });
|
|
47
|
+
if (options.fixture)
|
|
48
|
+
params.set('preview', options.fixture);
|
|
49
|
+
if (options.project)
|
|
50
|
+
params.set('ctxProject', options.project);
|
|
51
|
+
if (options.entities)
|
|
52
|
+
params.set('ctxEntities', options.entities);
|
|
53
|
+
if (options.page)
|
|
54
|
+
params.set('ctxPage', options.page);
|
|
55
|
+
const url = `${base.replace(/\/$/, '')}/?${params}`;
|
|
56
|
+
const { chromium } = await loadPlaywright(options.root);
|
|
57
|
+
const browser = await chromium.launch();
|
|
58
|
+
const result = {
|
|
59
|
+
ok: false,
|
|
60
|
+
mode,
|
|
61
|
+
url,
|
|
62
|
+
slot: options.slot,
|
|
63
|
+
id: options.id,
|
|
64
|
+
contributionErrors: [],
|
|
65
|
+
errors: [],
|
|
66
|
+
warnings: [],
|
|
67
|
+
toasts: [],
|
|
68
|
+
};
|
|
69
|
+
let ready = false;
|
|
70
|
+
try {
|
|
71
|
+
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
|
72
|
+
await context.grantPermissions(['clipboard-read', 'clipboard-write'], { origin: base });
|
|
73
|
+
const page = await context.newPage();
|
|
74
|
+
page.on('console', (message) => {
|
|
75
|
+
const text = message.text();
|
|
76
|
+
// React's development warnings are console errors, but not failures
|
|
77
|
+
if (message.type() === 'error' && !text.startsWith('Warning:'))
|
|
78
|
+
result.errors.push(text);
|
|
79
|
+
else if (message.type() === 'error' || message.type() === 'warning')
|
|
80
|
+
result.warnings.push(text);
|
|
81
|
+
});
|
|
82
|
+
page.on('pageerror', (error) => result.errors.push(error.stack || error.message));
|
|
83
|
+
await page.goto(url);
|
|
84
|
+
const timeout = options.timeout ?? 30000;
|
|
85
|
+
try {
|
|
86
|
+
await page.waitForFunction(() => window.__AYON_ADDON_SHELL__?.ready === true, undefined, { timeout });
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
const text = await page.locator('body').innerText();
|
|
90
|
+
result.errors.push(`the stage didn't get ready in ${timeout} ms: ${text.slice(0, 300)}`);
|
|
91
|
+
}
|
|
92
|
+
// the contribution's own requests and lazy pieces
|
|
93
|
+
await page.waitForLoadState('networkidle').catch(() => undefined);
|
|
94
|
+
await page.waitForTimeout(1000);
|
|
95
|
+
ready = await page.evaluate(() => window.__AYON_ADDON_SHELL__?.ready === true);
|
|
96
|
+
if (options.run && ready) {
|
|
97
|
+
await page.evaluate(() => window.__AYON_ADDON_SHELL__.run());
|
|
98
|
+
await page.waitForTimeout(1000);
|
|
99
|
+
result.clipboard = await page.evaluate(() => navigator.clipboard.readText()).catch(() => undefined);
|
|
100
|
+
}
|
|
101
|
+
const status = await page.evaluate(() => {
|
|
102
|
+
const shell = window.__AYON_ADDON_SHELL__;
|
|
103
|
+
return shell && { fixture: shell.fixture(), unmatched: shell.unmatched, used: shell.used };
|
|
104
|
+
});
|
|
105
|
+
result.context = status?.fixture?.context;
|
|
106
|
+
if (mode === 'preview')
|
|
107
|
+
result.unmatched = status?.unmatched;
|
|
108
|
+
// without the box's icon name
|
|
109
|
+
result.contributionErrors = (await page.locator('.ayon-addon-error').allInnerTexts()).map((text) => text.replace(/^error\s+/, ''));
|
|
110
|
+
result.toasts = await page.locator('.Toastify__toast').allInnerTexts();
|
|
111
|
+
if (options.prune && options.fixture && status && !status.unmatched.length) {
|
|
112
|
+
const file = path.join(options.root, 'dev', 'fixtures', `${options.fixture}.json`);
|
|
113
|
+
const fixture = JSON.parse(readFileSync(file, 'utf8'));
|
|
114
|
+
const keys = Object.keys(fixture.responses);
|
|
115
|
+
fixture.responses = Object.fromEntries(keys.filter((key) => status.used.includes(key)).map((key) => [key, fixture.responses[key]]));
|
|
116
|
+
writeFileSync(file, `${JSON.stringify(fixture, null, 2)}\n`);
|
|
117
|
+
result.pruned = keys.length - Object.keys(fixture.responses).length;
|
|
118
|
+
}
|
|
119
|
+
if (options.saveFixture && status?.fixture) {
|
|
120
|
+
const file = path.join(options.root, 'dev', 'fixtures', `${options.saveFixture}.json`);
|
|
121
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
122
|
+
writeFileSync(file, `${JSON.stringify(status.fixture, null, 2)}\n`);
|
|
123
|
+
result.savedFixture = path.relative(options.root, file);
|
|
124
|
+
}
|
|
125
|
+
if (options.screenshot) {
|
|
126
|
+
const file = path.resolve(options.root, options.screenshot);
|
|
127
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
128
|
+
await page.screenshot({ path: file });
|
|
129
|
+
result.screenshot = file;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
await browser.close();
|
|
134
|
+
await server?.close();
|
|
135
|
+
}
|
|
136
|
+
// a failed contribution logs an "[addon <name>]" error (ContributionView, runContribution)
|
|
137
|
+
const addonErrors = result.errors.filter((error) => error.startsWith('[addon '));
|
|
138
|
+
result.ok =
|
|
139
|
+
ready &&
|
|
140
|
+
!!result.context &&
|
|
141
|
+
result.contributionErrors.length === 0 &&
|
|
142
|
+
addonErrors.length === 0 &&
|
|
143
|
+
!(result.unmatched?.length);
|
|
144
|
+
return result;
|
|
145
|
+
};
|
|
146
|
+
export const formatBrowse = (result) => [
|
|
147
|
+
`${result.ok ? 'ok' : 'FAILED'}: ${result.slot} ${result.id} (${result.mode}) ${result.url}`,
|
|
148
|
+
...result.contributionErrors.map((error) => ` error box: ${error}`),
|
|
149
|
+
...result.errors.map((error) => ` console error: ${error.split('\n')[0]}`),
|
|
150
|
+
...result.toasts.map((toast) => ` toast: ${toast}`),
|
|
151
|
+
...(result.clipboard ? [` clipboard: ${result.clipboard}`] : []),
|
|
152
|
+
...(result.unmatched || []).map((key) => ` not in the fixture: ${key}`),
|
|
153
|
+
...(result.savedFixture ? [` saved ${result.savedFixture}`] : []),
|
|
154
|
+
...(result.pruned !== undefined ? [` pruned ${result.pruned} unused answers from the fixture`] : []),
|
|
155
|
+
...(result.screenshot ? [` screenshot ${result.screenshot}`] : []),
|
|
156
|
+
].join('\n');
|
package/dist/check.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface CheckResult {
|
|
2
|
+
ok: boolean;
|
|
3
|
+
addon: {
|
|
4
|
+
name: string;
|
|
5
|
+
version: string;
|
|
6
|
+
};
|
|
7
|
+
manifest: string;
|
|
8
|
+
sdkVersion?: string;
|
|
9
|
+
contributions: {
|
|
10
|
+
slot: string;
|
|
11
|
+
id: string;
|
|
12
|
+
key: string;
|
|
13
|
+
pieces: Record<string, string | null>;
|
|
14
|
+
}[];
|
|
15
|
+
problems: {
|
|
16
|
+
slot?: string;
|
|
17
|
+
id?: string;
|
|
18
|
+
reason: string;
|
|
19
|
+
}[];
|
|
20
|
+
types?: {
|
|
21
|
+
ok: boolean;
|
|
22
|
+
errors: string[];
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export declare const checkAddon: ({ root, manifest, types, }?: {
|
|
26
|
+
root?: string;
|
|
27
|
+
manifest?: string;
|
|
28
|
+
types?: boolean;
|
|
29
|
+
}) => Promise<CheckResult>;
|
|
30
|
+
export declare const formatCheck: (result: CheckResult) => string;
|
package/dist/check.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// `ayon-addon check`: the manifest the way AYON reads it (slots, ids, fields, pieces), that every
|
|
2
|
+
// piece's file exists, and the addon's types.
|
|
3
|
+
import { spawnSync } from 'child_process';
|
|
4
|
+
import { existsSync, mkdirSync, writeFileSync } from 'fs';
|
|
5
|
+
import { createRequire } from 'module';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { pathToFileURL } from 'url';
|
|
8
|
+
import { importFromProject, installedVersion, readAddonProject } from './project.js';
|
|
9
|
+
const EXTENSIONS = ['', '.ts', '.tsx', '.js', '.jsx', '/index.ts', '/index.tsx', '/index.js'];
|
|
10
|
+
// a piece is `() => import('./File')`: find the file it imports
|
|
11
|
+
const pieceFile = (root, manifestDir, piece) => {
|
|
12
|
+
if (typeof piece !== 'function')
|
|
13
|
+
return undefined;
|
|
14
|
+
const specifier = String(piece).match(/import\(\s*["'`]([^"'`]+)["'`]\s*\)/)?.[1];
|
|
15
|
+
if (!specifier)
|
|
16
|
+
return null;
|
|
17
|
+
const base = specifier.startsWith('@/')
|
|
18
|
+
? path.join(root, 'src', specifier.slice(2))
|
|
19
|
+
: path.resolve(manifestDir, specifier);
|
|
20
|
+
const found = EXTENSIONS.map((ext) => base + ext).find((file) => existsSync(file));
|
|
21
|
+
return found ? path.relative(root, found) : `missing: ${specifier}`;
|
|
22
|
+
};
|
|
23
|
+
// bundles the manifest without its pieces (dynamic imports stay imports) and imports it
|
|
24
|
+
const loadManifest = async (root, manifestPath) => {
|
|
25
|
+
const vite = await importFromProject(root, 'vite');
|
|
26
|
+
const output = await vite.build({
|
|
27
|
+
root,
|
|
28
|
+
configFile: false,
|
|
29
|
+
logLevel: 'silent',
|
|
30
|
+
resolve: { alias: { '@': path.join(root, 'src') } },
|
|
31
|
+
plugins: [
|
|
32
|
+
{
|
|
33
|
+
name: 'ayon-addon:check',
|
|
34
|
+
resolveDynamicImport: (specifier) => typeof specifier === 'string' ? { id: specifier, external: true } : null,
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
build: { ssr: manifestPath, write: false, minify: false, rollupOptions: { output: { format: 'es' } } },
|
|
38
|
+
});
|
|
39
|
+
const outputs = (Array.isArray(output) ? output : [output]);
|
|
40
|
+
const entry = outputs.flatMap((o) => o.output).find((chunk) => chunk.type === 'chunk' && chunk.isEntry);
|
|
41
|
+
if (!entry?.code)
|
|
42
|
+
throw new Error(`could not build ${manifestPath}`);
|
|
43
|
+
const dir = path.join(root, 'node_modules', '.ayon-addon');
|
|
44
|
+
mkdirSync(dir, { recursive: true });
|
|
45
|
+
const file = path.join(dir, 'manifest-check.mjs');
|
|
46
|
+
writeFileSync(file, entry.code);
|
|
47
|
+
const module = await import(`${pathToFileURL(file).href}?t=${Date.now()}`);
|
|
48
|
+
return module.default;
|
|
49
|
+
};
|
|
50
|
+
const typeCheck = (root) => {
|
|
51
|
+
const require = createRequire(path.join(root, 'package.json'));
|
|
52
|
+
const tsc = require.resolve('typescript/bin/tsc');
|
|
53
|
+
const result = spawnSync(process.execPath, [tsc, '--noEmit', '-p', path.join(root, 'tsconfig.json')], {
|
|
54
|
+
cwd: root,
|
|
55
|
+
encoding: 'utf8',
|
|
56
|
+
});
|
|
57
|
+
const errors = `${result.stdout}${result.stderr}`
|
|
58
|
+
.split('\n')
|
|
59
|
+
.filter((line) => / error TS\d+/.test(line));
|
|
60
|
+
return { ok: result.status === 0, errors };
|
|
61
|
+
};
|
|
62
|
+
export const checkAddon = async ({ root = process.cwd(), manifest = 'src/manifest.ts', types = true, } = {}) => {
|
|
63
|
+
const project = readAddonProject(root);
|
|
64
|
+
const manifestPath = path.resolve(root, manifest);
|
|
65
|
+
const result = {
|
|
66
|
+
ok: true,
|
|
67
|
+
addon: { name: project.name, version: project.version },
|
|
68
|
+
manifest,
|
|
69
|
+
contributions: [],
|
|
70
|
+
problems: [],
|
|
71
|
+
};
|
|
72
|
+
const sdk = await importFromProject(root, '@ynput/ayon-addon/check');
|
|
73
|
+
result.sdkVersion = installedVersion(root, '@ynput/ayon-addon');
|
|
74
|
+
let loaded;
|
|
75
|
+
try {
|
|
76
|
+
loaded = await loadManifest(root, manifestPath);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
result.problems.push({ reason: `the manifest failed to load: ${error.message}` });
|
|
80
|
+
}
|
|
81
|
+
if (loaded !== undefined) {
|
|
82
|
+
const { contributions, skipped } = sdk.checkManifest({ name: project.name, version: project.version, remote: 'check' }, loaded);
|
|
83
|
+
result.problems.push(...skipped.map(({ slot, id, reason }) => ({ slot, id, reason })));
|
|
84
|
+
for (const contribution of contributions) {
|
|
85
|
+
const definition = sdk.SLOTS[contribution.slot];
|
|
86
|
+
const pieceNames = [...definition.pieces, ...(definition.optionalPieces || [])];
|
|
87
|
+
const pieces = {};
|
|
88
|
+
for (const name of pieceNames) {
|
|
89
|
+
const file = pieceFile(root, path.dirname(manifestPath), contribution[name]);
|
|
90
|
+
if (file === undefined)
|
|
91
|
+
continue;
|
|
92
|
+
pieces[name] = file;
|
|
93
|
+
if (file?.startsWith('missing:')) {
|
|
94
|
+
result.problems.push({ slot: contribution.slot, id: contribution.id, reason: `${name}: ${file}` });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
result.contributions.push({ slot: contribution.slot, id: contribution.id, key: contribution.key, pieces });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (types)
|
|
101
|
+
result.types = typeCheck(root);
|
|
102
|
+
result.ok = !result.problems.length && result.types?.ok !== false;
|
|
103
|
+
return result;
|
|
104
|
+
};
|
|
105
|
+
export const formatCheck = (result) => {
|
|
106
|
+
const lines = [
|
|
107
|
+
`${result.addon.name} ${result.addon.version}, ${result.manifest} (@ynput/ayon-addon ${result.sdkVersion})`,
|
|
108
|
+
...result.contributions.map((c) => ` ok ${c.slot.padEnd(30)} ${c.id.padEnd(24)} ${Object.values(c.pieces).filter(Boolean).join(', ')}`),
|
|
109
|
+
...result.problems.map((p) => ` !! ${(p.slot || '').padEnd(30)} ${(p.id || '').padEnd(24)} ${p.reason}`),
|
|
110
|
+
];
|
|
111
|
+
if (result.types) {
|
|
112
|
+
lines.push(result.types.ok ? ' ok types' : ` !! types: ${result.types.errors.length} errors`);
|
|
113
|
+
lines.push(...result.types.errors.slice(0, 20).map((e) => ` ${e}`));
|
|
114
|
+
}
|
|
115
|
+
lines.push(result.ok ? 'check passed' : 'check failed');
|
|
116
|
+
return lines.join('\n');
|
|
117
|
+
};
|
package/dist/cli.d.ts
ADDED