@things-factory/figure-ui 10.1.49 → 10.1.50
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/client/graphql/v3-store.ts +2 -1
- package/client/modeller/figure-history-panel.ts +9 -1
- package/client/modeller/figure-v3-first-save.ts +98 -0
- package/client/modeller/figure-v3-links.ts +89 -26
- package/client/modeller/figure-v3-parts.ts +1 -0
- package/client/modeller/figure-v3-properties.ts +2 -1
- package/client/modeller/v3-canvas.ts +61 -7
- package/client/modeller/v3-editor.ts +91 -21
- package/client/modeller/v3-first-save.ts +34 -0
- package/client/modeller/v3-gizmo.ts +52 -3
- package/client/modeller/v3-joint-drag.ts +116 -0
- package/client/modeller/v3-joint-fan.ts +24 -11
- package/client/modeller/v3-keys.ts +12 -1
- package/client/modeller/v3-links.ts +72 -5
- package/client/modeller/v3-live.ts +11 -0
- package/client/modeller/v3-product-session.ts +4 -2
- package/client/modeller/v3-refusals.ts +21 -0
- package/client/pages/figure-v3-modeller-page.ts +46 -7
- package/dist-client/graphql/v3-store.js +2 -1
- package/dist-client/graphql/v3-store.js.map +1 -1
- package/dist-client/modeller/figure-history-panel.js +9 -1
- package/dist-client/modeller/figure-history-panel.js.map +1 -1
- package/dist-client/modeller/figure-v3-first-save.d.ts +20 -0
- package/dist-client/modeller/figure-v3-first-save.js +118 -0
- package/dist-client/modeller/figure-v3-first-save.js.map +1 -0
- package/dist-client/modeller/figure-v3-links.d.ts +5 -0
- package/dist-client/modeller/figure-v3-links.js +81 -23
- package/dist-client/modeller/figure-v3-links.js.map +1 -1
- package/dist-client/modeller/figure-v3-parts.js +1 -0
- package/dist-client/modeller/figure-v3-parts.js.map +1 -1
- package/dist-client/modeller/figure-v3-properties.js +2 -1
- package/dist-client/modeller/figure-v3-properties.js.map +1 -1
- package/dist-client/modeller/v3-canvas.d.ts +11 -0
- package/dist-client/modeller/v3-canvas.js +61 -7
- package/dist-client/modeller/v3-canvas.js.map +1 -1
- package/dist-client/modeller/v3-editor.d.ts +13 -1
- package/dist-client/modeller/v3-editor.js +91 -21
- package/dist-client/modeller/v3-editor.js.map +1 -1
- package/dist-client/modeller/v3-first-save.d.ts +17 -0
- package/dist-client/modeller/v3-first-save.js +27 -0
- package/dist-client/modeller/v3-first-save.js.map +1 -0
- package/dist-client/modeller/v3-gizmo.d.ts +18 -0
- package/dist-client/modeller/v3-gizmo.js +45 -2
- package/dist-client/modeller/v3-gizmo.js.map +1 -1
- package/dist-client/modeller/v3-joint-drag.d.ts +19 -0
- package/dist-client/modeller/v3-joint-drag.js +97 -0
- package/dist-client/modeller/v3-joint-drag.js.map +1 -0
- package/dist-client/modeller/v3-joint-fan.js +24 -11
- package/dist-client/modeller/v3-joint-fan.js.map +1 -1
- package/dist-client/modeller/v3-keys.d.ts +9 -1
- package/dist-client/modeller/v3-keys.js +3 -0
- package/dist-client/modeller/v3-keys.js.map +1 -1
- package/dist-client/modeller/v3-links.d.ts +45 -2
- package/dist-client/modeller/v3-links.js +52 -3
- package/dist-client/modeller/v3-links.js.map +1 -1
- package/dist-client/modeller/v3-live.d.ts +6 -0
- package/dist-client/modeller/v3-live.js +10 -0
- package/dist-client/modeller/v3-live.js.map +1 -1
- package/dist-client/modeller/v3-product-session.d.ts +4 -1
- package/dist-client/modeller/v3-product-session.js +2 -2
- package/dist-client/modeller/v3-product-session.js.map +1 -1
- package/dist-client/modeller/v3-refusals.d.ts +4 -0
- package/dist-client/modeller/v3-refusals.js +21 -0
- package/dist-client/modeller/v3-refusals.js.map +1 -0
- package/dist-client/pages/figure-v3-modeller-page.d.ts +4 -0
- package/dist-client/pages/figure-v3-modeller-page.js +45 -7
- package/dist-client/pages/figure-v3-modeller-page.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/package.json +7 -5
- package/test/browser/build.mjs +62 -0
- package/test/browser/modeller-page.js +198 -0
- package/test/browser/modeller.check.mjs +334 -0
- package/test/browser/serve.mjs +43 -0
- package/test/joints.ts +33 -0
- package/test/v3-clips.test.ts +3 -2
- package/test/v3-controls.test.ts +3 -2
- package/test/v3-fields.test.ts +3 -2
- package/test/v3-first-save.test.ts +75 -0
- package/test/v3-gizmo.test.ts +38 -5
- package/test/v3-joint-drag.test.ts +93 -0
- package/test/v3-joint-fan.test.ts +16 -6
- package/test/v3-keys.test.ts +15 -2
- package/test/v3-links.test.ts +62 -14
- package/test/v3-live.test.ts +23 -4
- package/test/v3-maturity.test.ts +2 -6
- package/test/v3-refusals.test.ts +28 -0
- package/translations/en.json +33 -5
- package/translations/ja.json +32 -4
- package/translations/ko.json +32 -4
- package/translations/ms.json +32 -4
- package/translations/zh.json +32 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/figure-ui",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.50",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public",
|
|
6
6
|
"@things-factory:registry": "https://registry.npmjs.org"
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"scripts": {
|
|
19
19
|
"build": "npm run build:server && npm run build:client",
|
|
20
20
|
"test": "node --import ./test/resolve-ts-siblings.mjs --test --test-force-exit test/*.test.ts",
|
|
21
|
+
"test:browser": "node --test --test-force-exit test/browser/modeller.check.mjs",
|
|
21
22
|
"copy:files": "copyfiles -e \"./client/**/*.{ts,js,json}\" -u 1 \"./client/**/*\" dist-client",
|
|
22
23
|
"build:client": "npm run copy:files && npx tsc --p ./client/tsconfig.json",
|
|
23
24
|
"build:server": "npx tsc --p ./server/tsconfig.json",
|
|
@@ -26,19 +27,20 @@
|
|
|
26
27
|
"clean": "npm run clean:server && npm run clean:client"
|
|
27
28
|
},
|
|
28
29
|
"dependencies": {
|
|
29
|
-
"@hatiolab/figure-model": "^0.1.
|
|
30
|
-
"@hatiolab/things-scene": "^10.1.
|
|
30
|
+
"@hatiolab/figure-model": "^0.1.57",
|
|
31
|
+
"@hatiolab/things-scene": "^10.1.34",
|
|
31
32
|
"@material/web": "^2.0.0",
|
|
32
33
|
"@operato/data-grist": "^10.0.0",
|
|
33
34
|
"@operato/graphql": "^10.0.0",
|
|
34
35
|
"@operato/i18n": "^10.0.0",
|
|
35
36
|
"@operato/layout": "^10.28.0",
|
|
36
37
|
"@operato/p13n": "^10.0.0",
|
|
38
|
+
"@operato/popup": "^10.23.0",
|
|
37
39
|
"@operato/shell": "^10.0.0",
|
|
38
40
|
"@operato/styles": "^10.0.0",
|
|
39
41
|
"@operato/utils": "^10.0.0",
|
|
40
|
-
"@things-factory/figure-service": "^10.1.
|
|
42
|
+
"@things-factory/figure-service": "^10.1.50",
|
|
41
43
|
"three": "^0.185.1"
|
|
42
44
|
},
|
|
43
|
-
"gitHead": "
|
|
45
|
+
"gitHead": "4700d39d70eb5fae4ad56cf47ea0b966c8516ae2"
|
|
44
46
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Builds the page the browser check drives: emits `client/` fresh (never a dist-client left from an earlier build --
|
|
3
|
+
* a stale bundle passes a check the source fails) and bundles it with the page into one file.
|
|
4
|
+
*
|
|
5
|
+
* FIGURE_MODEL=<dir of a figure-model build> bundles that build instead of the installed one -- for a change to
|
|
6
|
+
* figure-model that is not published yet.
|
|
7
|
+
*/
|
|
8
|
+
import { execFileSync } from 'node:child_process'
|
|
9
|
+
import { mkdirSync, rmSync, existsSync } from 'node:fs'
|
|
10
|
+
import { tmpdir } from 'node:os'
|
|
11
|
+
import { dirname, join, resolve } from 'node:path'
|
|
12
|
+
import { fileURLToPath } from 'node:url'
|
|
13
|
+
import { rollup } from 'rollup'
|
|
14
|
+
import nodeResolve from '@rollup/plugin-node-resolve'
|
|
15
|
+
import commonjs from '@rollup/plugin-commonjs'
|
|
16
|
+
import replace from '@rollup/plugin-replace'
|
|
17
|
+
|
|
18
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
19
|
+
const PACKAGE = resolve(HERE, '../..')
|
|
20
|
+
const ROOT = resolve(PACKAGE, '../..')
|
|
21
|
+
|
|
22
|
+
export async function buildPage() {
|
|
23
|
+
const out = join(tmpdir(), 'figure-ui-browser-check')
|
|
24
|
+
const emitted = join(out, 'client')
|
|
25
|
+
rmSync(out, { recursive: true, force: true })
|
|
26
|
+
mkdirSync(out, { recursive: true })
|
|
27
|
+
|
|
28
|
+
/* Types are the unit suite's and tsc's business; here only what runs. */
|
|
29
|
+
execFileSync(join(ROOT, 'node_modules/.bin/tsc'), ['-p', join(PACKAGE, 'client/tsconfig.json'), '--outDir', emitted, '--noCheck', '--declaration', 'false', '--incremental', 'false'], {
|
|
30
|
+
stdio: 'inherit'
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const figureModel = process.env.FIGURE_MODEL ? resolve(process.env.FIGURE_MODEL, 'dist/index.js') : undefined
|
|
34
|
+
if (figureModel && !existsSync(figureModel)) throw Error(`FIGURE_MODEL has no build: ${figureModel}`)
|
|
35
|
+
|
|
36
|
+
const bundle = await rollup({
|
|
37
|
+
input: join(HERE, 'modeller-page.js'),
|
|
38
|
+
onwarn(warning, warn) {
|
|
39
|
+
if (warning.code === 'CIRCULAR_DEPENDENCY' || warning.code === 'THIS_IS_UNDEFINED' || warning.code === 'EVAL') return
|
|
40
|
+
warn(warning)
|
|
41
|
+
},
|
|
42
|
+
plugins: [
|
|
43
|
+
{
|
|
44
|
+
name: 'figure-ui-client',
|
|
45
|
+
resolveId(id) {
|
|
46
|
+
if (id.startsWith('figure-ui-client/')) return join(emitted, id.slice('figure-ui-client/'.length))
|
|
47
|
+
if (figureModel && id === '@hatiolab/figure-model') return figureModel
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
replace({ preventAssignment: true, values: { 'process.env.NODE_ENV': JSON.stringify('production') } }),
|
|
52
|
+
nodeResolve({ browser: true, modulePaths: [join(PACKAGE, 'node_modules'), join(ROOT, 'node_modules')] }),
|
|
53
|
+
commonjs()
|
|
54
|
+
]
|
|
55
|
+
})
|
|
56
|
+
const file = join(out, 'page.js')
|
|
57
|
+
await bundle.write({ file, format: 'esm', inlineDynamicImports: true })
|
|
58
|
+
await bundle.close()
|
|
59
|
+
return file
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) console.log(await buildPage())
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The page the browser check drives: the V3 editor as the modeller page mounts it, over a store in memory, so the check
|
|
3
|
+
* needs no server and no sign-in. Everything the editor draws and does is the product's own code, emitted fresh from
|
|
4
|
+
* `client/` by the runner.
|
|
5
|
+
*/
|
|
6
|
+
import 'figure-ui-client/modeller/v3-editor.js'
|
|
7
|
+
import { createV3ProductSession } from 'figure-ui-client/modeller/v3-product-session.js'
|
|
8
|
+
import { addV3Box, convertV2ToV3, createV3Asset, robotArmV3, serializeV3Asset } from '@hatiolab/figure-model'
|
|
9
|
+
|
|
10
|
+
const PALETTE = {
|
|
11
|
+
'palette.dark': '#2b2b2b',
|
|
12
|
+
'palette.neutral': '#9a9a9a',
|
|
13
|
+
'palette.primary': '#477b9e',
|
|
14
|
+
'palette.accent': '#f79c40',
|
|
15
|
+
'palette.light': '#dddddd'
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function sourceOf(kind, v2) {
|
|
19
|
+
if (kind === 'arm') return serializeV3Asset(robotArmV3())
|
|
20
|
+
if (kind === 'box') {
|
|
21
|
+
const asset = createV3Asset('Probe')
|
|
22
|
+
asset.palette = { ...asset.palette, ...PALETTE }
|
|
23
|
+
return serializeV3Asset(addV3Box(asset, { id: 'box', size: { x: 400, y: 400, z: 400 }, position: { x: 0, y: 200, z: 0 } }))
|
|
24
|
+
}
|
|
25
|
+
const result = convertV2ToV3(v2, { palette: PALETTE })
|
|
26
|
+
if (result.status !== 'converted') throw Error(`not converted: ${JSON.stringify(result.refusals)}`)
|
|
27
|
+
return serializeV3Asset(result.asset)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function deep(selector, root = document) {
|
|
31
|
+
const found = [...root.querySelectorAll(selector)]
|
|
32
|
+
for (const element of root.querySelectorAll('*')) if (element.shadowRoot) found.push(...deep(selector, element.shadowRoot))
|
|
33
|
+
return found
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const editor = () => document.querySelector('figure-v3-editor')
|
|
37
|
+
const canvas = () => deep('figure-v3-canvas')[0]
|
|
38
|
+
const three = () => canvas().scene.root._threeCapability
|
|
39
|
+
const controls = () => three().gizmoManager.controls
|
|
40
|
+
|
|
41
|
+
/** A world point on the page, in CSS pixels. */
|
|
42
|
+
function onPage(world) {
|
|
43
|
+
const camera = three().cameraManager.activeCamera
|
|
44
|
+
const box = three().rendererManager.domElement.getBoundingClientRect()
|
|
45
|
+
const v = world.clone().project(camera)
|
|
46
|
+
return { x: box.left + ((v.x + 1) / 2) * box.width, y: box.top + ((1 - v.y) / 2) * box.height, inFront: v.z < 1 }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function centreOf(element) {
|
|
50
|
+
element.scrollIntoView({ block: 'center' })
|
|
51
|
+
const box = element.getBoundingClientRect()
|
|
52
|
+
return { x: box.left + box.width / 2, y: box.top + box.height / 2 }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/*
|
|
56
|
+
* What the check reads. Only reading: every change the check makes goes through the mouse and the keyboard, as a
|
|
57
|
+
* person's would.
|
|
58
|
+
*/
|
|
59
|
+
window.probe = {
|
|
60
|
+
state() {
|
|
61
|
+
const view = editor().session.read()
|
|
62
|
+
return {
|
|
63
|
+
revision: view.revision,
|
|
64
|
+
selected: editor().selected,
|
|
65
|
+
error: editor().error,
|
|
66
|
+
mode: three().gizmoManager.mode,
|
|
67
|
+
attached: three().gizmoManager.attached?.name ?? (three().gizmoManager.attached ? '(object)' : null),
|
|
68
|
+
axis: controls()?.axis ?? null,
|
|
69
|
+
dragging: three().gizmoManager.isDragging
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
/** The evaluated body of a part: where it is and its size, as the session holds it. */
|
|
73
|
+
body(id) {
|
|
74
|
+
const g = editor().session.read().geometry.find(g => g.id === id)
|
|
75
|
+
return g ? { t: g.pose.t, r: g.pose.r, dimensions: g.shape.dimensions } : null
|
|
76
|
+
},
|
|
77
|
+
fields(prefix) {
|
|
78
|
+
return Object.fromEntries(editor().session.read().fields.filter(f => f.id.startsWith(prefix)).map(f => [f.id, f.value]))
|
|
79
|
+
},
|
|
80
|
+
/** The page point of a drawn part's centre, for a click that picks it. */
|
|
81
|
+
partOnPage(id) {
|
|
82
|
+
const component = canvas().scene.root.components.find(c => c.state.name === id)
|
|
83
|
+
const object = component?._realObject?.object3d
|
|
84
|
+
if (!object) return null
|
|
85
|
+
object.updateWorldMatrix(true, true)
|
|
86
|
+
const centre = object.getWorldPosition(object.position.clone())
|
|
87
|
+
return onPage(centre)
|
|
88
|
+
},
|
|
89
|
+
/**
|
|
90
|
+
* Page points on the gizmo's handle for an axis in the current mode: vertices of the handle's picker (the invisible
|
|
91
|
+
* shape the gizmo hit-tests), nearest the viewer first. The check hovers them until the gizmo says it holds that axis.
|
|
92
|
+
*/
|
|
93
|
+
handlePoints(axis) {
|
|
94
|
+
const c = controls()
|
|
95
|
+
const picker = c?._gizmo?.picker?.[c.mode]
|
|
96
|
+
if (!picker) return []
|
|
97
|
+
picker.updateMatrixWorld(true)
|
|
98
|
+
const points = []
|
|
99
|
+
for (const handle of picker.children.filter(h => h.name === axis)) {
|
|
100
|
+
const position = handle.geometry.attributes.position
|
|
101
|
+
const v = handle.position.clone()
|
|
102
|
+
for (let i = 0; i < position.count; i += Math.max(1, Math.floor(position.count / 24))) {
|
|
103
|
+
v.fromBufferAttribute(position, i).applyMatrix4(handle.matrixWorld)
|
|
104
|
+
const p = onPage(v)
|
|
105
|
+
if (p.inFront) points.push(p)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return points
|
|
109
|
+
},
|
|
110
|
+
/**
|
|
111
|
+
* Which way on the page to pull a rotate ring so it turns: the gizmo turns by the pull along (axis × eye), the
|
|
112
|
+
* direction to the camera (three's TransformControls). A unit vector in page pixels.
|
|
113
|
+
*/
|
|
114
|
+
turnDirection(axis) {
|
|
115
|
+
const c = controls()
|
|
116
|
+
const object = three().gizmoManager.attached
|
|
117
|
+
if (!c || !object) return null
|
|
118
|
+
object.updateWorldMatrix(true, false)
|
|
119
|
+
const at = object.getWorldPosition(object.position.clone())
|
|
120
|
+
const camera = three().cameraManager.activeCamera
|
|
121
|
+
const eye = camera.getWorldPosition(at.clone()).sub(at).normalize()
|
|
122
|
+
const unit = at.clone().set(axis === 'X' ? 1 : 0, axis === 'Y' ? 1 : 0, axis === 'Z' ? 1 : 0)
|
|
123
|
+
if (c.space === 'local') unit.applyQuaternion(object.getWorldQuaternion(object.quaternion.clone()))
|
|
124
|
+
const tangent = unit.cross(eye).normalize()
|
|
125
|
+
const size = at.distanceTo(camera.getWorldPosition(at.clone())) * 0.05
|
|
126
|
+
const a = onPage(at), b = onPage(at.clone().add(tangent.multiplyScalar(size)))
|
|
127
|
+
const l = Math.hypot(b.x - a.x, b.y - a.y) || 1
|
|
128
|
+
return { x: (b.x - a.x) / l, y: (b.y - a.y) / l }
|
|
129
|
+
},
|
|
130
|
+
/** The page point of a part's row in the parts list. */
|
|
131
|
+
row(id) {
|
|
132
|
+
const label = deep('span[label]').find(s => s.getAttribute('title') === id)
|
|
133
|
+
return label ? centreOf(label) : null
|
|
134
|
+
},
|
|
135
|
+
/** The page point of a side tab, by its label (the i18n key: the check loads no translations). */
|
|
136
|
+
tab(label) {
|
|
137
|
+
const button = deep('button[tab]').find(b => b.getAttribute('aria-label') === label)
|
|
138
|
+
return button ? centreOf(button) : null
|
|
139
|
+
},
|
|
140
|
+
/** Where a slider's thumb is and how many pixels one unit of it spans. */
|
|
141
|
+
slider(label) {
|
|
142
|
+
const input = deep('input[type=range]').find(r => r.getAttribute('aria-label') === label)
|
|
143
|
+
if (!input) return null
|
|
144
|
+
input.scrollIntoView({ block: 'center' })
|
|
145
|
+
const box = input.getBoundingClientRect()
|
|
146
|
+
const min = Number(input.min),
|
|
147
|
+
max = Number(input.max),
|
|
148
|
+
value = Number(input.value)
|
|
149
|
+
const thumb = 8
|
|
150
|
+
const x = box.left + thumb + ((value - min) / (max - min)) * (box.width - 2 * thumb)
|
|
151
|
+
return { x, y: box.top + box.height / 2, pxPerUnit: (box.width - 2 * thumb) / (max - min), value }
|
|
152
|
+
},
|
|
153
|
+
/** A joint record as the session holds it. */
|
|
154
|
+
joint(id) {
|
|
155
|
+
return (JSON.parse(editor().session.source()).joints ?? []).find(j => j.id === id) ?? null
|
|
156
|
+
},
|
|
157
|
+
/** A state or design value as the session holds it. */
|
|
158
|
+
value(id) {
|
|
159
|
+
return editor().session.read().fields.find(f => f.id === id)?.value ?? null
|
|
160
|
+
},
|
|
161
|
+
/** The world centre of a part's top or bottom face, from the session's evaluation. */
|
|
162
|
+
face(id, which) {
|
|
163
|
+
const g = editor().session.read().geometry.find(g => g.id === id)
|
|
164
|
+
if (!g) return null
|
|
165
|
+
const d = g.shape.dimensions
|
|
166
|
+
const half = (d.height ?? d.length) / 2
|
|
167
|
+
const s = which === 'top' ? half : -half
|
|
168
|
+
return [0, 1, 2].map(i => g.pose.t[i] + g.pose.r[i][1] * s)
|
|
169
|
+
},
|
|
170
|
+
/** Whether the joint fan stands on the board now. */
|
|
171
|
+
fan() {
|
|
172
|
+
return canvas().scene.root.components.some(c => c.state.name === '__joint-fan')
|
|
173
|
+
},
|
|
174
|
+
/** The drawn position of a part's component, as the canvas shows it now. */
|
|
175
|
+
drawn(id) {
|
|
176
|
+
const c = canvas().scene.root.components.find(c => c.state.name === id)
|
|
177
|
+
return c ? [c.state.left, c.state.top, c.state.zPos].map(Math.round) : null
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
window.harness = {
|
|
182
|
+
async mount(kind, v2) {
|
|
183
|
+
const source = sourceOf(kind, v2)
|
|
184
|
+
const session = createV3ProductSession({
|
|
185
|
+
load: async id => ({ id, source }),
|
|
186
|
+
save: async request => ({ id: request.id ?? 'probe', source: request.source })
|
|
187
|
+
})
|
|
188
|
+
await session.open('probe')
|
|
189
|
+
const editor = document.createElement('figure-v3-editor')
|
|
190
|
+
editor.session = session
|
|
191
|
+
editor.revision = 0
|
|
192
|
+
/* The modeller page counts a change the same way: the editor re-reads the session on a new revision. */
|
|
193
|
+
editor.addEventListener('v3-changed', () => (editor.revision = editor.revision + 1))
|
|
194
|
+
document.body.replaceChildren(editor)
|
|
195
|
+
await editor.updateComplete
|
|
196
|
+
return session.read().revision
|
|
197
|
+
}
|
|
198
|
+
}
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The V3 modeller driven with the mouse, in a real browser.
|
|
3
|
+
*
|
|
4
|
+
* The unit suite proves the rules; it cannot prove what happens between a pointer going down on a gizmo handle and
|
|
5
|
+
* coming up -- the scene writes the component on every move, the editor must not fight it, and the drag must land as
|
|
6
|
+
* one step that stays. Every failure the user found in the gizmo (2026-09-24: selection lost, a converted part
|
|
7
|
+
* springing back, scale growing then snapping back) lived there, and each had been declared working from code. So
|
|
8
|
+
* here the check does what a person does: clicks the part, presses the mode key, hovers until the gizmo holds the
|
|
9
|
+
* handle, drags, lets go -- and then reads the session.
|
|
10
|
+
*
|
|
11
|
+
* Needs the sibling figure-model checkout for the V2 robot arm it converts (as things-scene's browser checks do).
|
|
12
|
+
*
|
|
13
|
+
* Run: npm run test:browser
|
|
14
|
+
* FIGURE_MODEL=../../../figure-model npm run test:browser (a figure-model build not published yet)
|
|
15
|
+
*/
|
|
16
|
+
import { test, describe, before, after } from 'node:test'
|
|
17
|
+
import assert from 'node:assert/strict'
|
|
18
|
+
import { readFileSync } from 'node:fs'
|
|
19
|
+
import { dirname, resolve } from 'node:path'
|
|
20
|
+
import { fileURLToPath } from 'node:url'
|
|
21
|
+
|
|
22
|
+
import { buildPage } from './build.mjs'
|
|
23
|
+
import { openPage } from './serve.mjs'
|
|
24
|
+
|
|
25
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
26
|
+
const FIXTURE = resolve(HERE, '../../../../../figure-model/src/fixtures/robot-arm-6dof.json')
|
|
27
|
+
|
|
28
|
+
const sleep = ms => new Promise(done => setTimeout(done, ms))
|
|
29
|
+
|
|
30
|
+
/** The V2 six-axis arm, without the tool-seat marker (a slot anchor with no material, which V3 carries as a place). */
|
|
31
|
+
function robotArm() {
|
|
32
|
+
const source = JSON.parse(readFileSync(FIXTURE, 'utf-8'))
|
|
33
|
+
source.parts = source.parts.filter(part => part.material)
|
|
34
|
+
delete source.slots
|
|
35
|
+
return source
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let opened
|
|
39
|
+
before(async () => {
|
|
40
|
+
opened = await openPage(await buildPage())
|
|
41
|
+
})
|
|
42
|
+
after(async () => {
|
|
43
|
+
await opened?.close()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const page = () => opened.page
|
|
47
|
+
const read = (fn, ...args) => page().evaluate(fn, ...args)
|
|
48
|
+
const state = () => read(() => probe.state())
|
|
49
|
+
const body = id => read(id => probe.body(id), id)
|
|
50
|
+
const fields = id => read(id => probe.fields(id + '.'), id)
|
|
51
|
+
|
|
52
|
+
async function mount(kind) {
|
|
53
|
+
await read((kind, v2) => harness.mount(kind, v2), kind, kind === 'box' ? null : robotArm())
|
|
54
|
+
/* The board is built and the camera fitted after the first frames. */
|
|
55
|
+
await sleep(2500)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function click(point) {
|
|
59
|
+
assert.ok(point, 'nothing on the page to click')
|
|
60
|
+
await page().mouse.click(point.x, point.y)
|
|
61
|
+
await sleep(400)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const KEYS = { translate: 'KeyW', rotate: 'KeyE', scale: 'KeyR' }
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Drags a gizmo handle `pixels` outward along it, as a hand would: the mode key (the scene's toggle -- pressed only
|
|
68
|
+
* when the gizmo is not already on in that mode), a hover until the gizmo says it holds the handle, ten moves, release.
|
|
69
|
+
*/
|
|
70
|
+
async function dragHandle(part, mode, axis, pixels) {
|
|
71
|
+
const s = await state()
|
|
72
|
+
if (s.mode !== mode || !s.attached) {
|
|
73
|
+
await page().keyboard.press(KEYS[mode])
|
|
74
|
+
await sleep(300)
|
|
75
|
+
}
|
|
76
|
+
assert.ok((await state()).attached, `the ${mode} gizmo is on the selected part`)
|
|
77
|
+
const centre = await read(id => probe.partOnPage(id), part)
|
|
78
|
+
let hold
|
|
79
|
+
for (const point of await read(axis => probe.handlePoints(axis), axis)) {
|
|
80
|
+
await page().mouse.move(point.x, point.y)
|
|
81
|
+
await sleep(20)
|
|
82
|
+
if ((await state()).axis === axis) {
|
|
83
|
+
hold = point
|
|
84
|
+
break
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
assert.ok(hold, `the mouse finds the ${mode} ${axis} handle`)
|
|
88
|
+
let dx = hold.x - centre.x,
|
|
89
|
+
dy = hold.y - centre.y
|
|
90
|
+
const length = Math.hypot(dx, dy) || 1
|
|
91
|
+
dx /= length
|
|
92
|
+
dy /= length
|
|
93
|
+
/* A rotate ring turns by the pull along (axis × eye): pull that way. */
|
|
94
|
+
if (mode === 'rotate') ({ x: dx, y: dy } = await read(axis => probe.turnDirection(axis), axis))
|
|
95
|
+
await page().mouse.down()
|
|
96
|
+
for (let i = 1; i <= 10; i++) {
|
|
97
|
+
await page().mouse.move(hold.x + (dx * pixels * i) / 10, hold.y + (dy * pixels * i) / 10)
|
|
98
|
+
await sleep(30)
|
|
99
|
+
}
|
|
100
|
+
await page().mouse.up()
|
|
101
|
+
await sleep(600)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** After the release, and again a second later: a value that springs back shows only in the second reading. */
|
|
105
|
+
async function settled(part) {
|
|
106
|
+
const now = await body(part)
|
|
107
|
+
await sleep(1000)
|
|
108
|
+
assert.deepEqual(await body(part), now, `${part} stays where the drag left it`)
|
|
109
|
+
return now
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const moved = (a, b) => a.map((v, i) => Math.round(b[i] - v))
|
|
113
|
+
|
|
114
|
+
describe('the gizmo on a part the modeller made (own numbers)', () => {
|
|
115
|
+
before(async () => {
|
|
116
|
+
await mount('box')
|
|
117
|
+
await click(await read(() => probe.partOnPage('box')))
|
|
118
|
+
assert.equal((await state()).selected, 'box', 'a click on the part selects it')
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('translate: one step, the part moves along the handle and stays, still selected', async () => {
|
|
122
|
+
const start = await state(),
|
|
123
|
+
was = await body('box')
|
|
124
|
+
await dragHandle('box', 'translate', 'X', 80)
|
|
125
|
+
const now = await settled('box')
|
|
126
|
+
const after = await state()
|
|
127
|
+
assert.equal(after.revision, start.revision + 1, 'one step of the history')
|
|
128
|
+
assert.equal(after.selected, 'box')
|
|
129
|
+
assert.ok(after.attached, 'the gizmo is still on it')
|
|
130
|
+
const [x, y, z] = moved(was.t, now.t)
|
|
131
|
+
assert.ok(Math.abs(x) > 20 && y === 0 && z === 0, `moved along X only: ${[x, y, z]}`)
|
|
132
|
+
assert.deepEqual(now.dimensions, was.dimensions)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('scale: one step, the part grows along the handle and keeps the size', async () => {
|
|
136
|
+
const start = await state(),
|
|
137
|
+
was = await body('box')
|
|
138
|
+
await dragHandle('box', 'scale', 'X', 80)
|
|
139
|
+
const now = await settled('box')
|
|
140
|
+
const after = await state()
|
|
141
|
+
assert.equal(after.revision, start.revision + 1, 'one step of the history')
|
|
142
|
+
assert.equal(after.selected, 'box')
|
|
143
|
+
assert.ok(now.dimensions.width > was.dimensions.width * 1.2, `grew: ${was.dimensions.width} → ${now.dimensions.width}`)
|
|
144
|
+
assert.equal(now.dimensions.height, was.dimensions.height)
|
|
145
|
+
assert.equal(now.dimensions.depth, was.dimensions.depth)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
test('rotate: one step, the part turns about the handle axis and keeps the turn', async () => {
|
|
149
|
+
const start = await state(),
|
|
150
|
+
was = await fields('box')
|
|
151
|
+
await dragHandle('box', 'rotate', 'Y', 80)
|
|
152
|
+
await settled('box')
|
|
153
|
+
const now = await fields('box')
|
|
154
|
+
const after = await state()
|
|
155
|
+
assert.equal(after.revision, start.revision + 1, 'one step of the history')
|
|
156
|
+
assert.equal(after.selected, 'box')
|
|
157
|
+
assert.ok(Math.abs(now['box.rotation.y'] - was['box.rotation.y']) > 10, `turned about Y: ${was['box.rotation.y']} → ${now['box.rotation.y']}`)
|
|
158
|
+
assert.equal(now['box.rotation.x'], was['box.rotation.x'])
|
|
159
|
+
assert.equal(now['box.rotation.z'], was['box.rotation.z'])
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
describe('the gizmo on a converted part (worked-out rules)', () => {
|
|
164
|
+
before(async () => {
|
|
165
|
+
await mount('v2')
|
|
166
|
+
await click(await read(() => probe.row('pedestal')))
|
|
167
|
+
assert.equal((await state()).selected, 'pedestal', 'a click on the row selects it')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('translate: one step (nudge-part), the part moves and stays, still selected', async () => {
|
|
171
|
+
const start = await state(),
|
|
172
|
+
was = await body('pedestal')
|
|
173
|
+
await dragHandle('pedestal', 'translate', 'X', 80)
|
|
174
|
+
const now = await settled('pedestal')
|
|
175
|
+
const after = await state()
|
|
176
|
+
assert.equal(after.error, '', 'not refused')
|
|
177
|
+
assert.equal(after.revision, start.revision + 1, 'one step of the history')
|
|
178
|
+
assert.equal(after.selected, 'pedestal')
|
|
179
|
+
const [x, y, z] = moved(was.t, now.t)
|
|
180
|
+
assert.ok(Math.abs(x) > 20 && y === 0 && z === 0, `moved along X only: ${[x, y, z]}`)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('scale: one step (nudge-part grow), the cylinder widens and keeps it', async () => {
|
|
184
|
+
const start = await state(),
|
|
185
|
+
was = await body('pedestal')
|
|
186
|
+
await dragHandle('pedestal', 'scale', 'X', 80)
|
|
187
|
+
const now = await settled('pedestal')
|
|
188
|
+
const after = await state()
|
|
189
|
+
assert.equal(after.error, '', 'not refused')
|
|
190
|
+
assert.equal(after.revision, start.revision + 1, 'one step of the history')
|
|
191
|
+
assert.equal(after.selected, 'pedestal')
|
|
192
|
+
assert.ok(now.dimensions.radius > was.dimensions.radius * 1.1, `wider: ${was.dimensions.radius} → ${now.dimensions.radius}`)
|
|
193
|
+
assert.equal(now.dimensions.length, was.dimensions.length)
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test('undo with the canvas focused takes the whole drag back, and nothing writes it again', async () => {
|
|
197
|
+
const was = await body('pedestal')
|
|
198
|
+
await dragHandle('pedestal', 'translate', 'X', 80)
|
|
199
|
+
const dragged = await state()
|
|
200
|
+
const modifier = process.platform === 'darwin' ? 'Meta' : 'Control'
|
|
201
|
+
await page().keyboard.down(modifier)
|
|
202
|
+
await page().keyboard.press('KeyZ')
|
|
203
|
+
await page().keyboard.up(modifier)
|
|
204
|
+
await sleep(1200)
|
|
205
|
+
assert.deepEqual(await body('pedestal'), was, 'back where it was before the drag')
|
|
206
|
+
assert.equal((await state()).revision, dragged.revision + 1, 'the undo, and no write after it')
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test('a part a joint places is refused, said, and drawn back where the joint puts it', async () => {
|
|
210
|
+
await click(await read(() => probe.row('upper-arm')))
|
|
211
|
+
assert.equal((await state()).selected, 'upper-arm')
|
|
212
|
+
const start = await state(),
|
|
213
|
+
was = await body('upper-arm'),
|
|
214
|
+
drawn = await read(() => probe.drawn('upper-arm'))
|
|
215
|
+
await dragHandle('upper-arm', 'translate', 'X', 80)
|
|
216
|
+
await sleep(1000)
|
|
217
|
+
const after = await state()
|
|
218
|
+
assert.equal(after.revision, start.revision, 'nothing written')
|
|
219
|
+
assert.notEqual(after.error, '', 'the refusal is said')
|
|
220
|
+
assert.equal(after.selected, 'upper-arm', 'the selection stays')
|
|
221
|
+
assert.deepEqual(await body('upper-arm'), was)
|
|
222
|
+
assert.deepEqual(await read(() => probe.drawn('upper-arm')), drawn, 'the canvas draws it back')
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
describe('a pose slider dragged with the mouse (user 2026-09-24: the arm jumped on release)', () => {
|
|
227
|
+
before(async () => {
|
|
228
|
+
await mount('v2')
|
|
229
|
+
await click(await read(() => probe.tab('figure.label.motion')))
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
/** Presses a slider's thumb, moves it `units`, reads the canvas while still held, then releases. */
|
|
233
|
+
async function dragSlider(label, units) {
|
|
234
|
+
const thumb = await read(label => probe.slider(label), label)
|
|
235
|
+
assert.ok(thumb, `the ${label} slider is on the page`)
|
|
236
|
+
await page().mouse.move(thumb.x, thumb.y)
|
|
237
|
+
await page().mouse.down()
|
|
238
|
+
for (let i = 1; i <= 10; i++) {
|
|
239
|
+
await page().mouse.move(thumb.x + (thumb.pxPerUnit * units * i) / 10, thumb.y)
|
|
240
|
+
await sleep(40)
|
|
241
|
+
}
|
|
242
|
+
await sleep(300)
|
|
243
|
+
const held = await read(() => probe.drawn('finger-left'))
|
|
244
|
+
await page().mouse.up()
|
|
245
|
+
await sleep(700)
|
|
246
|
+
return { held, released: await read(() => probe.drawn('finger-left')) }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
test('while one joint is dragged the others keep the pose already set, so the release lands where the drag showed', async () => {
|
|
250
|
+
const base = await dragSlider('Base pan (J1)', 60)
|
|
251
|
+
assert.deepEqual(base.held, base.released, 'the first joint: held and released agree')
|
|
252
|
+
const pan = (await read(() => probe.slider('Base pan (J1)'))).value
|
|
253
|
+
assert.ok(Math.abs(pan) > 20, `the base turned: ${pan}`)
|
|
254
|
+
|
|
255
|
+
const shoulder = await dragSlider('Shoulder lift (J2)', 30)
|
|
256
|
+
assert.deepEqual(shoulder.held, shoulder.released, `drawn while held ${shoulder.held}, after release ${shoulder.released}`)
|
|
257
|
+
assert.equal((await read(() => probe.slider('Base pan (J1)'))).value, pan, 'the base keeps its turn')
|
|
258
|
+
assert.notDeepEqual(shoulder.released, base.released, 'the shoulder moved the tip')
|
|
259
|
+
})
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
describe('the V3 robot arm: a drag on a part edits its joint (ADR-0093)', () => {
|
|
263
|
+
before(async () => {
|
|
264
|
+
await mount('arm')
|
|
265
|
+
await click(await read(() => probe.row('upper-arm')))
|
|
266
|
+
assert.equal((await state()).selected, 'upper-arm')
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
test('the upper arm shows the shoulder joint\'s fan', async () => {
|
|
270
|
+
assert.equal(await read(() => probe.fan()), true)
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
test('turning the upper arm with the rotate handle turns J2: one step, still on the shoulder, undone whole', async () => {
|
|
274
|
+
const start = await state()
|
|
275
|
+
const was = await read(() => probe.value('j2-shoulder'))
|
|
276
|
+
await dragHandle('upper-arm', 'rotate', 'Z', 120)
|
|
277
|
+
await sleep(600)
|
|
278
|
+
const after = await state()
|
|
279
|
+
const now = await read(() => probe.value('j2-shoulder'))
|
|
280
|
+
assert.equal(after.error, '', 'not refused')
|
|
281
|
+
assert.equal(after.revision, start.revision + 1, 'one step')
|
|
282
|
+
assert.equal(after.selected, 'upper-arm')
|
|
283
|
+
assert.ok(Math.abs(now - was) > 5, `J2 turned: ${was} -> ${now}`)
|
|
284
|
+
const gap = (await read(() => probe.face('upper-arm', 'bottom'))).map((v, i) => v - 0)
|
|
285
|
+
const top = await read(() => probe.face('shoulder', 'top'))
|
|
286
|
+
assert.ok(Math.hypot(...gap.map((v, i) => v - top[i])) < 1e-6, 'the upper arm still starts on the shoulder top')
|
|
287
|
+
const modifier = process.platform === 'darwin' ? 'Meta' : 'Control'
|
|
288
|
+
await page().keyboard.down(modifier)
|
|
289
|
+
await page().keyboard.press('KeyZ')
|
|
290
|
+
await page().keyboard.up(modifier)
|
|
291
|
+
await sleep(900)
|
|
292
|
+
assert.equal(await read(() => probe.value('j2-shoulder')), was, 'undo takes the turn back')
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
test('scaling the upper arm with the R handle makes it longer: one step, still on the shoulder, the elbow carried', async () => {
|
|
296
|
+
await click(await read(() => probe.row('upper-arm')))
|
|
297
|
+
const start = await state()
|
|
298
|
+
const was = await read(() => probe.value('upper-arm.dimension.height'))
|
|
299
|
+
await dragHandle('upper-arm', 'scale', 'Y', 80)
|
|
300
|
+
await sleep(600)
|
|
301
|
+
const after = await state()
|
|
302
|
+
const now = await read(() => probe.value('upper-arm.dimension.height'))
|
|
303
|
+
assert.equal(after.error, '', 'not refused')
|
|
304
|
+
assert.equal(after.revision, start.revision + 1, 'one step')
|
|
305
|
+
assert.ok(now > was * 1.1, `longer: ${was} -> ${now}`)
|
|
306
|
+
const bottom = await read(() => probe.face('upper-arm', 'bottom'))
|
|
307
|
+
const top = await read(() => probe.face('shoulder', 'top'))
|
|
308
|
+
assert.ok(Math.hypot(...bottom.map((v, i) => v - top[i])) < 1e-6, 'still starts on the shoulder top')
|
|
309
|
+
const elbow = await read(() => probe.face('elbow', 'bottom'))
|
|
310
|
+
const end = await read(() => probe.face('upper-arm', 'top'))
|
|
311
|
+
assert.ok(Math.hypot(...elbow.map((v, i) => v - end[i])) < 1e-6, 'the elbow sits on the longer arm\'s end')
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
test('moving the elbow block moves where its joint sits; the forearm comes with it', async () => {
|
|
315
|
+
await click(await read(() => probe.row('elbow')))
|
|
316
|
+
const start = await state()
|
|
317
|
+
const forearm = await read(() => probe.face('forearm', 'bottom'))
|
|
318
|
+
await dragHandle('elbow', 'translate', 'X', 60)
|
|
319
|
+
await sleep(600)
|
|
320
|
+
const after = await state()
|
|
321
|
+
assert.equal(after.error, '', 'not refused')
|
|
322
|
+
assert.equal(after.revision, start.revision + 1, 'one step')
|
|
323
|
+
const joint = await read(() => probe.joint('elbow'))
|
|
324
|
+
assert.ok(joint?.origin?.align?.x?.mm, `the joint's place across the face: ${JSON.stringify(joint?.origin)}`)
|
|
325
|
+
const moved = await read(() => probe.face('forearm', 'bottom'))
|
|
326
|
+
const top = await read(() => probe.face('elbow', 'top'))
|
|
327
|
+
assert.ok(Math.abs(moved[0] - forearm[0]) > 5, 'the forearm moved with it')
|
|
328
|
+
assert.ok(Math.hypot(...moved.map((v, i) => v - top[i])) < 1e-6, 'and still starts on the elbow top')
|
|
329
|
+
})
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
test('the page raised no error', () => {
|
|
333
|
+
assert.deepEqual(opened.errors, [])
|
|
334
|
+
})
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Opens the built page in a headless browser: a tiny server for page.js, and puppeteer on it at a desktop size.
|
|
3
|
+
*/
|
|
4
|
+
import { createServer } from 'node:http'
|
|
5
|
+
import { readFileSync } from 'node:fs'
|
|
6
|
+
import puppeteer from 'puppeteer'
|
|
7
|
+
|
|
8
|
+
const HTML = `<!DOCTYPE html>
|
|
9
|
+
<html><head><meta charset="utf-8"><style>
|
|
10
|
+
html, body { margin: 0; height: 100%; }
|
|
11
|
+
body { display: flex; font-family: sans-serif; }
|
|
12
|
+
figure-v3-editor { flex: 1; min-width: 0; min-height: 0; }
|
|
13
|
+
</style></head>
|
|
14
|
+
<body><script type="module" src="/page.js"></script></body></html>`
|
|
15
|
+
|
|
16
|
+
export async function openPage(bundle, { width = 1400, height = 900 } = {}) {
|
|
17
|
+
const js = readFileSync(bundle)
|
|
18
|
+
const server = createServer((req, res) => {
|
|
19
|
+
if (req.url === '/page.js') {
|
|
20
|
+
res.writeHead(200, { 'Content-Type': 'application/javascript' })
|
|
21
|
+
res.end(js)
|
|
22
|
+
} else {
|
|
23
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
24
|
+
res.end(HTML)
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
await new Promise(done => server.listen(0, done))
|
|
28
|
+
const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox'] })
|
|
29
|
+
const page = await browser.newPage()
|
|
30
|
+
await page.setViewport({ width, height })
|
|
31
|
+
const errors = []
|
|
32
|
+
page.on('pageerror', e => errors.push(String(e)))
|
|
33
|
+
await page.goto(`http://localhost:${server.address().port}/`)
|
|
34
|
+
await page.waitForFunction(() => !!window.harness, { timeout: 30000 })
|
|
35
|
+
return {
|
|
36
|
+
page,
|
|
37
|
+
errors,
|
|
38
|
+
async close() {
|
|
39
|
+
await browser.close()
|
|
40
|
+
server.close()
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|