Compare commits

...

37 Commits

Author SHA1 Message Date
b090f9959b fix macos 2024-06-03 11:49:46 +10:00
37f0f7b568 refactor test utils into test-utils 2024-06-03 09:56:18 +10:00
08c1745be0 fmt 2024-05-31 12:25:47 -04:00
a157dfe6d7 fmt 2024-05-31 12:22:19 -04:00
80b6688d04 A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) 2024-05-31 16:12:59 +00:00
b7b87a38c4 fmt 2024-05-31 12:10:11 -04:00
ab56a165a4 Bump to 2 workers 2024-05-31 12:06:18 -04:00
9bcc307f32 Fix flakiness in for segments tests 2024-05-31 10:17:59 -04:00
5a3c6a3858 Fix tests unrelated to this PR 2024-05-31 10:16:21 -04:00
280f08945c ci again 2024-05-31 10:15:17 -04:00
e9fb3f7256 A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) 2024-05-31 10:15:15 -04:00
56f0c43e4e Correct a vitest 2024-05-31 10:14:49 -04:00
4f9e8cbe15 Please the Playwright. Praise the Playwright. 2024-05-31 10:14:49 -04:00
0f21ca90c8 Add new instructions on running Playwright anywhere 2024-05-31 10:14:49 -04:00
c0bed02d72 Remove unused variables 2024-05-31 10:14:49 -04:00
3ab33e3810 run ci pls again 2024-05-31 10:14:49 -04:00
27ba89f867 A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) 2024-05-31 10:14:49 -04:00
c52e4dcbe6 run ci pls 2024-05-31 10:14:49 -04:00
791c0487ae run ci pls 2024-05-31 10:14:49 -04:00
bd9c02fde9 A snapshot a day keeps the bugs away! 📷🐛 (OS: ubuntu) 2024-05-31 10:14:49 -04:00
cda70687f4 Incorporate ping health; yarn make:dev; faster video stream loss notice 2024-05-31 10:14:49 -04:00
a7d785ab88 Fix typing 2024-05-31 10:14:49 -04:00
16a64b55db fmt 2024-05-31 10:14:49 -04:00
343ed04f7d wip 2024-05-31 10:14:49 -04:00
66ddce1348 wip 2024-05-31 10:14:49 -04:00
fade3b3995 Fix tsc 2024-05-31 10:14:49 -04:00
8e7465a823 Revert awaiting on lsp failure 2024-05-31 10:14:49 -04:00
05521d3ef3 Fix up types 2024-05-31 10:14:49 -04:00
37df4c6fc9 Fix formatting 2024-05-31 10:14:49 -04:00
db08e67215 Show when authentication is bad (cookie header only) 2024-05-31 10:14:49 -04:00
32f2411394 Catch LSP errors on bad auth 2024-05-31 10:14:49 -04:00
cc0377bb00 Don't do any stream events if network is not ok 2024-05-31 10:14:49 -04:00
ff08cef9f8 Refactor to use Context API for network status 2024-05-31 10:14:49 -04:00
39a6d265f2 Remove unused variable 2024-05-31 10:14:49 -04:00
545e89f7aa Add new error states to network status notification 2024-05-31 10:14:49 -04:00
d6ae23d881 Fix build errors 2024-05-31 10:14:49 -04:00
25b599d3e7 Reapply "Add ping pong health, remove a timeout interval, fix up netwo… (#1771)
This reverts commit 1913519f68.
2024-05-31 10:14:49 -04:00
32 changed files with 1384 additions and 960 deletions

14
Makefile Normal file
View File

@ -0,0 +1,14 @@
.PHONY: dev
WASM_LIB_FILES := $(wildcard src/wasm-lib/**/*.rs)
dev: node_modules public/wasm_lib_bg.wasm
yarn start
public/wasm_lib_bg.wasm: $(WASM_LIB_FILES)
yarn build:wasm-dev
node_modules: package.json
package.json:
yarn install

View File

@ -197,28 +197,32 @@ For more information on fuzzing you can check out
### Playwright ### Playwright
First time running plawright locally, you'll need to add the secrets file For a portable way to run Playwright you'll need Docker.
After that, open a terminal and run:
```bash ```bash
touch ./e2e/playwright/playwright-secrets.env docker run --network host --rm --init -it playwright/chrome:playwright-1.43.1
printf 'token="your-token"\nsnapshottoken="your-snapshot-token"' > ./e2e/playwright/playwright-secrets.env
``` ```
and in another terminal, run:
```bash
PW_TEST_CONNECT_WS_ENDPOINT=ws://127.0.0.1:4444/ yarn playwright test --project="Google Chrome" <test suite>
```
An example of a `<test suite>` is: `e2e/playwright/flow-tests.spec.ts`
YOU WILL NEED A PLAYWRIGHT-SECRETS.ENV FILE:
```bash
# ./e2e/playwright/playwright-secrets.env
token=<your-token>
snapshottoken=<your-snapshot-token>
```
then replace "your-token" with a dev token from dev.zoo.dev/account/api-tokens then replace "your-token" with a dev token from dev.zoo.dev/account/api-tokens
then:
run playwright
```
yarn playwright test
```
run a specific test suite
```
yarn playwright test src/e2e-tests/example.spec.ts
```
run a specific test change the test from `test('...` to `test.only('...` run a specific test change the test from `test('...` to `test.only('...`
(note if you commit this, the tests will instantly fail without running any of the tests) (note if you commit this, the tests will instantly fail without running any of the tests)

View File

@ -27,6 +27,8 @@ document.addEventListener('mousemove', (e) =>
) )
*/ */
const deg = (Math.PI * 2) / 360
const commonPoints = { const commonPoints = {
startAt: '[9.06, -12.22]', startAt: '[9.06, -12.22]',
num1: 9.14, num1: 9.14,
@ -656,6 +658,7 @@ test('re-executes', async ({ page }) => {
).toBeVisible() ).toBeVisible()
}) })
test.describe('Can create sketches on all planes and their back sides', () => {
const sketchOnPlaneAndBackSideTest = async ( const sketchOnPlaneAndBackSideTest = async (
page: any, page: any,
plane: string, plane: string,
@ -722,8 +725,6 @@ const sketchOnPlaneAndBackSideTest = async (
await u.clearCommandLogs() await u.clearCommandLogs()
await u.removeCurrentCode() await u.removeCurrentCode()
} }
test.describe('Can create sketches on all planes and their back sides', () => {
test('XY', async ({ page }) => { test('XY', async ({ page }) => {
await sketchOnPlaneAndBackSideTest( await sketchOnPlaneAndBackSideTest(
page, page,
@ -1484,12 +1485,15 @@ const part001 = startSketchOn('XZ')
test('Can add multiple sketches', async ({ page }) => { test('Can add multiple sketches', async ({ page }) => {
test.skip(process.platform === 'darwin', 'Can add multiple sketches') test.skip(process.platform === 'darwin', 'Can add multiple sketches')
const u = await getUtils(page) const u = await getUtils(page)
await page.setViewportSize({ width: 1200, height: 500 }) const viewportSize = { width: 1200, height: 500 }
const PUR = 400 / 37.5 //pixeltoUnitRatio await page.setViewportSize(viewportSize)
await page.goto('/') await page.goto('/')
await u.waitForAuthSkipAppStart() await u.waitForAuthSkipAppStart()
await u.openDebugPanel() await u.openDebugPanel()
const center = { x: viewportSize.width / 2, y: viewportSize.height / 2 }
u.click00rSetCenter(center.x, center.y)
await expect( await expect(
page.getByRole('button', { name: 'Start Sketch' }) page.getByRole('button', { name: 'Start Sketch' })
).not.toBeDisabled() ).not.toBeDisabled()
@ -1502,127 +1506,71 @@ test('Can add multiple sketches', async ({ page }) => {
200 200
) )
// select a plane let codeStr = "const part001 = startSketchOn('XY')"
await page.mouse.click(700, 200)
await expect(page.locator('.cm-content')).toHaveText(
`const part001 = startSketchOn('XZ')`
)
await page.mouse.click(center.x, viewportSize.height * 0.55)
await u.expectCodeToBe(codeStr)
await u.closeDebugPanel()
await page.waitForTimeout(500) // TODO detect animation ending, or disable animation await page.waitForTimeout(500) // TODO detect animation ending, or disable animation
const startXPx = 600 await u.click00r(0, 0)
await u.closeDebugPanel() codeStr += ` |> startProfileAt(${u.toSU([0, 0])}, %)`
await page.mouse.click(startXPx + PUR * 10, 500 - PUR * 10) await u.expectCodeToBe(codeStr)
await expect(page.locator('.cm-content'))
.toHaveText(`const part001 = startSketchOn('XZ')
|> startProfileAt(${commonPoints.startAt}, %)`)
await page.waitForTimeout(100)
await page.mouse.click(startXPx + PUR * 20, 500 - PUR * 10) await u.click00r(50, 0)
await page.waitForTimeout(100) codeStr += ` |> line(${u.toSU([50, 0])}, %)`
await u.expectCodeToBe(codeStr)
await expect(page.locator('.cm-content')) await u.click00r(0, 50)
.toHaveText(`const part001 = startSketchOn('XZ') codeStr += ` |> line(${u.toSU([0, 50])}, %)`
|> startProfileAt(${commonPoints.startAt}, %) await u.expectCodeToBe(codeStr)
|> line([${commonPoints.num1}, 0], %)`)
await page.mouse.click(startXPx + PUR * 20, 500 - PUR * 20) await u.click00r(-50, 0)
await expect(page.locator('.cm-content')) codeStr += ` |> line(${u.toSU([-50, 0])}, %)`
.toHaveText(`const part001 = startSketchOn('XZ') await u.expectCodeToBe(codeStr)
|> startProfileAt(${commonPoints.startAt}, %)
|> line([${commonPoints.num1}, 0], %)
|> line([0, ${commonPoints.num1}], %)`)
await page.waitForTimeout(100)
await page.mouse.click(startXPx, 500 - PUR * 20)
const finalCodeFirstSketch = `const part001 = startSketchOn('XZ')
|> startProfileAt(${commonPoints.startAt}, %)
|> line([${commonPoints.num1}, 0], %)
|> line([0, ${commonPoints.num1}], %)
|> line([-${commonPoints.num2}, 0], %)`
await expect(page.locator('.cm-content')).toHaveText(finalCodeFirstSketch)
// exit the sketch
// exit the sketch, reset relative clicker
await u.click00r(undefined, undefined)
await u.openAndClearDebugPanel() await u.openAndClearDebugPanel()
await page.getByRole('button', { name: 'Exit Sketch' }).click() await page.getByRole('button', { name: 'Exit Sketch' }).click()
await u.expectCmdLog('[data-message-type="execution-done"]') await u.expectCmdLog('[data-message-type="execution-done"]')
await u.updateCamPosition([100, 100, 100])
await page.waitForTimeout(250) await page.waitForTimeout(250)
await u.clearCommandLogs()
// start a new sketch // start a new sketch
await u.clearCommandLogs()
await page.getByRole('button', { name: 'Start Sketch' }).click() await page.getByRole('button', { name: 'Start Sketch' }).click()
await page.waitForTimeout(400)
await page.mouse.click(650, 450)
// when exiting the sketch above the camera is still looking down at XY,
// so selecting the plane again is a bit easier.
await page.mouse.click(center.x + 30, center.y)
await page.waitForTimeout(500) // TODO detect animation ending, or disable animation await page.waitForTimeout(500) // TODO detect animation ending, or disable animation
await u.clearAndCloseDebugPanel() codeStr += "const part002 = startSketchOn('XY')"
await u.expectCodeToBe(codeStr)
// on mock os there are issues with getting the camera to update
// it should not be selecting the 'XZ' plane here if the camera updated
// properly, but if we just role with it we can still verify everything
// in the rest of the test
const plane = process.platform === 'darwin' ? 'XZ' : 'XY'
await page.waitForTimeout(100)
await page.mouse.click(startXPx + PUR * 10, 500 - PUR * 10)
const startAt2 =
process.platform === 'darwin' ? '[9.75, -13.16]' : '[0.93, -1.25]'
await expect(
(await page.locator('.cm-content').innerText()).replace(/\s/g, '')
).toBe(
`${finalCodeFirstSketch}
const part002 = startSketchOn('${plane}')
|> startProfileAt(${startAt2}, %)`.replace(/\s/g, '')
)
await page.waitForTimeout(100)
await u.closeDebugPanel() await u.closeDebugPanel()
await page.mouse.click(startXPx + PUR * 20, 500 - PUR * 10)
await page.waitForTimeout(100)
const num2 = process.platform === 'darwin' ? 9.84 : 0.94 await u.click00r(30, 0)
await expect( codeStr += ` |> startProfileAt(${u.toSU([30, 0])}, %)`
(await page.locator('.cm-content').innerText()).replace(/\s/g, '') await u.expectCodeToBe(codeStr)
).toBe(
`${finalCodeFirstSketch}
const part002 = startSketchOn('${plane}')
|> startProfileAt(${startAt2}, %)
|> line([${num2}, 0], %)`.replace(/\s/g, '')
)
await page.mouse.click(startXPx + PUR * 20, 500 - PUR * 20) await u.click00r(30, 0)
await expect( codeStr += ` |> line(${u.toSU([30 - 0.1 /* imprecision */, 0])}, %)`
(await page.locator('.cm-content').innerText()).replace(/\s/g, '') await u.expectCodeToBe(codeStr)
).toBe(
`${finalCodeFirstSketch} await u.click00r(0, 30)
const part002 = startSketchOn('${plane}') codeStr += ` |> line(${u.toSU([0, 30])}, %)`
|> startProfileAt(${startAt2}, %) await u.expectCodeToBe(codeStr)
|> line([${num2}, 0], %)
|> line([0, ${roundOff( await u.click00r(-30, 0)
num2 + (process.platform === 'darwin' ? 0.01 : -0.01) codeStr += ` |> line(${u.toSU([-30 + 0.1, 0])}, %)`
)}], %)`.replace(/\s/g, '') await u.expectCodeToBe(codeStr)
)
await page.waitForTimeout(100) await u.click00r(undefined, undefined)
await page.mouse.click(startXPx, 500 - PUR * 20) await u.openAndClearDebugPanel()
await expect( await page.getByRole('button', { name: 'Exit Sketch' }).click()
(await page.locator('.cm-content').innerText()).replace(/\s/g, '') await u.expectCmdLog('[data-message-type="execution-done"]')
).toBe( await u.updateCamPosition([100, 100, 100])
`${finalCodeFirstSketch} await page.waitForTimeout(250)
const part002 = startSketchOn('${plane}') await u.clearCommandLogs()
|> startProfileAt(${startAt2}, %)
|> line([${num2}, 0], %)
|> line([0, ${roundOff(
num2 + (process.platform === 'darwin' ? 0.01 : -0.01)
)}], %)
|> line([-${process.platform === 'darwin' ? 19.59 : 1.87}, 0], %)`.replace(
/\s/g,
''
)
)
}) })
test('ProgramMemory can be serialised', async ({ page }) => { test('ProgramMemory can be serialised', async ({ page }) => {
@ -2105,6 +2053,7 @@ test('Can edit segments by dragging their handles', async ({ page }) => {
|> tangentialArcTo([26.92, -3.32], %)`) |> tangentialArcTo([26.92, -3.32], %)`)
}) })
test.describe('Snap to close works (at any scale)', () => {
const doSnapAtDifferentScales = async ( const doSnapAtDifferentScales = async (
page: any, page: any,
camPos: [number, number, number], camPos: [number, number, number],
@ -2127,7 +2076,9 @@ const doSnapAtDifferentScales = async (
await expect( await expect(
page.getByRole('button', { name: 'Start Sketch' }) page.getByRole('button', { name: 'Start Sketch' })
).not.toBeDisabled() ).not.toBeDisabled()
await expect(page.getByRole('button', { name: 'Start Sketch' })).toBeVisible() await expect(
page.getByRole('button', { name: 'Start Sketch' })
).toBeVisible()
await u.clearCommandLogs() await u.clearCommandLogs()
await page.getByRole('button', { name: 'Start Sketch' }).click() await page.getByRole('button', { name: 'Start Sketch' }).click()
@ -2175,10 +2126,9 @@ const doSnapAtDifferentScales = async (
await expect(page.locator('.cm-content')).toHaveText(code) await expect(page.locator('.cm-content')).toHaveText(code)
// Assert the tool was unequipped // Assert the tool was unequipped
await expect(page.getByRole('button', { name: 'Line' })).not.toHaveAttribute( await expect(
'aria-pressed', page.getByRole('button', { name: 'Line' })
'true' ).not.toHaveAttribute('aria-pressed', 'true')
)
// exit sketch // exit sketch
await u.openAndClearDebugPanel() await u.openAndClearDebugPanel()
@ -2186,8 +2136,6 @@ const doSnapAtDifferentScales = async (
await u.expectCmdLog('[data-message-type="execution-done"]') await u.expectCmdLog('[data-message-type="execution-done"]')
await u.removeCurrentCode() await u.removeCurrentCode()
} }
test.describe('Snap to close works (at any scale)', () => {
test('[0, 100, 100]', async ({ page }) => { test('[0, 100, 100]', async ({ page }) => {
await doSnapAtDifferentScales(page, [0, 100, 100], 0.01, 0.01) await doSnapAtDifferentScales(page, [0, 100, 100], 0.01, 0.01)
}) })
@ -2979,7 +2927,7 @@ test.describe('Testing segment overlays', () => {
* @param {number} options.steps - The number of steps to perform * @param {number} options.steps - The number of steps to perform
*/ */
const _clickConstrained = const _clickConstrained =
(page: Page) => (page: Page, u: any) =>
async ({ async ({
hoverPos, hoverPos,
constraintType, constraintType,
@ -2987,7 +2935,6 @@ test.describe('Testing segment overlays', () => {
expectAfterUnconstrained, expectAfterUnconstrained,
expectFinal, expectFinal,
ang = 45, ang = 45,
steps = 6,
}: { }: {
hoverPos: { x: number; y: number } hoverPos: { x: number; y: number }
constraintType: constraintType:
@ -3002,13 +2949,16 @@ test.describe('Testing segment overlays', () => {
steps?: number steps?: number
}) => { }) => {
await expect(page.getByText('Added variable')).not.toBeVisible() await expect(page.getByText('Added variable')).not.toBeVisible()
const [x, y] = [
Math.cos((ang * Math.PI) / 180) * 45,
Math.sin((ang * Math.PI) / 180) * 45,
]
await page.mouse.move(hoverPos.x + x, hoverPos.y + y) await page.mouse.move(0, 0)
await page.mouse.move(hoverPos.x, hoverPos.y, { steps }) await page.waitForTimeout(1000)
let x = 0,
y = 0
x = hoverPos.x + Math.cos(ang * deg) * 32
y = hoverPos.y - Math.sin(ang * deg) * 32
await page.mouse.move(x, y)
await u.wiggleMove(x, y, 20, 30, ang, 10, 5)
await expect(page.locator('.cm-content')).toContainText( await expect(page.locator('.cm-content')).toContainText(
expectBeforeUnconstrained expectBeforeUnconstrained
) )
@ -3024,6 +2974,14 @@ test.describe('Testing segment overlays', () => {
await expect(page.locator('.cm-content')).toContainText( await expect(page.locator('.cm-content')).toContainText(
expectAfterUnconstrained expectAfterUnconstrained
) )
await page.mouse.move(0, 0)
await page.waitForTimeout(1000)
x = hoverPos.x + Math.cos(ang * deg) * 32
y = hoverPos.y - Math.sin(ang * deg) * 32
await page.mouse.move(x, y)
await u.wiggleMove(x, y, 20, 30, ang, 10, 5)
const unconstrainedLocator = page.locator( const unconstrainedLocator = page.locator(
`[data-constraint-type="${constraintType}"][data-is-constrained="false"]` `[data-constraint-type="${constraintType}"][data-is-constrained="false"]`
) )
@ -3047,7 +3005,7 @@ test.describe('Testing segment overlays', () => {
* @param {number} options.steps - The number of steps to perform * @param {number} options.steps - The number of steps to perform
*/ */
const _clickUnconstrained = const _clickUnconstrained =
(page: Page) => (page: Page, u: any) =>
async ({ async ({
hoverPos, hoverPos,
constraintType, constraintType,
@ -3055,7 +3013,6 @@ test.describe('Testing segment overlays', () => {
expectAfterUnconstrained, expectAfterUnconstrained,
expectFinal, expectFinal,
ang = 45, ang = 45,
steps = 5,
}: { }: {
hoverPos: { x: number; y: number } hoverPos: { x: number; y: number }
constraintType: constraintType:
@ -3069,14 +3026,16 @@ test.describe('Testing segment overlays', () => {
ang?: number ang?: number
steps?: number steps?: number
}) => { }) => {
const [x, y] = [ await page.mouse.move(0, 0)
Math.cos((ang * Math.PI) / 180) * 45, await page.waitForTimeout(1000)
Math.sin((ang * Math.PI) / 180) * 45, let x = 0,
] y = 0
await page.mouse.move(hoverPos.x + x, hoverPos.y + y) x = hoverPos.x + Math.cos(ang * deg) * 32
y = hoverPos.y - Math.sin(ang * deg) * 32
await page.mouse.move(x, y)
await u.wiggleMove(x, y, 20, 30, ang, 10, 5)
await expect(page.getByText('Added variable')).not.toBeVisible() await expect(page.getByText('Added variable')).not.toBeVisible()
await page.mouse.move(hoverPos.x, hoverPos.y, { steps })
await expect(page.locator('.cm-content')).toContainText( await expect(page.locator('.cm-content')).toContainText(
expectBeforeUnconstrained expectBeforeUnconstrained
) )
@ -3094,7 +3053,14 @@ test.describe('Testing segment overlays', () => {
expectAfterUnconstrained expectAfterUnconstrained
) )
await expect(page.getByText('Added variable')).not.toBeVisible() await expect(page.getByText('Added variable')).not.toBeVisible()
await page.mouse.move(hoverPos.x, hoverPos.y, { steps })
await page.mouse.move(0, 0)
await page.waitForTimeout(1000)
x = hoverPos.x + Math.cos(ang * deg) * 32
y = hoverPos.y - Math.sin(ang * deg) * 32
await page.mouse.move(x, y)
await u.wiggleMove(x, y, 20, 30, ang, 10, 5)
const constrainedLocator = page.locator( const constrainedLocator = page.locator(
`[data-constraint-type="${constraintType}"][data-is-constrained="true"]` `[data-constraint-type="${constraintType}"][data-is-constrained="true"]`
) )
@ -3109,28 +3075,29 @@ test.describe('Testing segment overlays', () => {
test('for segments [line, angledLine, lineTo, xLineTo]', async ({ test('for segments [line, angledLine, lineTo, xLineTo]', async ({
page, page,
}) => { }) => {
test.setTimeout(120000)
await page.addInitScript(async () => { await page.addInitScript(async () => {
localStorage.setItem( localStorage.setItem(
'persistCode', 'persistCode',
`const part001 = startSketchOn('XZ') `const part001 = startSketchOn('XZ')
|> startProfileAt([0, 0], %) |> startProfileAt([5 + 0, 20 + 0], %)
|> line([0.5, -14 + 0], %) |> line([0.5, -14 + 0], %)
|> angledLine({ angle: 3 + 0, length: 32 + 0 }, %) |> angledLine({ angle: 3 + 0, length: 32 + 0 }, %)
|> lineTo([33, 11.5 + 0], %) |> lineTo([5 + 33, 20 + 11.5 + 0], %)
|> xLineTo(9 - 5, %) |> xLineTo(5 + 9 - 5, %)
|> yLineTo(-10.77, %, 'a') |> yLineTo(20 + -10.77, %, 'a')
|> xLine(26.04, %) |> xLine(26.04, %)
|> yLine(21.14 + 0, %) |> yLine(21.14 + 0, %)
|> angledLineOfXLength({ angle: 181 + 0, length: 23.14 }, %) |> angledLineOfXLength({ angle: 181 + 0, length: 23.14 }, %)
|> angledLineOfYLength({ angle: -91, length: 19 + 0 }, %) |> angledLineOfYLength({ angle: -91, length: 19 + 0 }, %)
|> angledLineToX({ angle: 3 + 0, to: 26 }, %) |> angledLineToX({ angle: 3 + 0, to: 5 + 26 }, %)
|> angledLineToY({ angle: 89, to: 9.14 + 0 }, %) |> angledLineToY({ angle: 89, to: 20 + 9.14 + 0 }, %)
|> angledLineThatIntersects({ |> angledLineThatIntersects({
angle: 4.14, angle: 4.14,
intersectTag: 'a', intersectTag: 'a',
offset: 9 offset: 9
}, %) }, %)
|> tangentialArcTo([3.14 + 13, 3.14], %) |> tangentialArcTo([5 + 3.14 + 13, 20 + 3.14], %)
` `
) )
}) })
@ -3164,56 +3131,75 @@ test.describe('Testing segment overlays', () => {
}, },
}) })
await page.waitForTimeout(100) await page.waitForTimeout(100)
await u.closeDebugPanel()
await page.getByText('xLineTo(9 - 5, %)').click() await page.getByText('xLineTo(5 + 9 - 5, %)').click()
await page.waitForTimeout(100) await page.waitForTimeout(100)
await page.getByRole('button', { name: 'Edit Sketch' }).click() await page.getByRole('button', { name: 'Edit Sketch' }).click()
await page.waitForTimeout(500) await page.waitForTimeout(500)
await expect(page.getByTestId('segment-overlay')).toHaveCount(13) await expect(page.getByTestId('segment-overlay')).toHaveCount(13)
const clickUnconstrained = _clickUnconstrained(page) const clickUnconstrained = _clickUnconstrained(page, u)
const clickConstrained = _clickConstrained(page) const clickConstrained = _clickConstrained(page, u)
// Drag the sketch into view
await page.mouse.move(600, 64)
await page.mouse.down({ button: 'middle' })
await page.mouse.move(600, 450, { steps: 10 })
await page.mouse.up({ button: 'middle' })
await page.mouse.move(600, 64)
await page.mouse.down({ button: 'middle' })
await page.mouse.move(600, 120, { steps: 10 })
await page.mouse.up({ button: 'middle' })
await page.waitForTimeout(100)
let ang = 0
const line = await u.getBoundingBox(`[data-overlay-index="${0}"]`) const line = await u.getBoundingBox(`[data-overlay-index="${0}"]`)
console.log('line1') ang = await u.getAngle(`[data-overlay-index="${0}"]`)
console.log('line1', line, ang)
await clickConstrained({ await clickConstrained({
hoverPos: { x: line.x, y: line.y - 10 }, hoverPos: { x: line.x, y: line.y },
constraintType: 'yRelative', constraintType: 'yRelative',
expectBeforeUnconstrained: '|> line([0.5, -14 + 0], %)', expectBeforeUnconstrained: '|> line([0.5, -14 + 0], %)',
expectAfterUnconstrained: '|> line([0.5, -14], %)', expectAfterUnconstrained: '|> line([0.5, -14], %)',
expectFinal: '|> line([0.5, yRel001], %)', expectFinal: '|> line([0.5, yRel001], %)',
ang: 135, ang: ang + 180,
}) })
console.log('line2') console.log('line2')
await clickUnconstrained({ await clickUnconstrained({
hoverPos: { x: line.x, y: line.y - 10 }, hoverPos: { x: line.x, y: line.y },
constraintType: 'xRelative', constraintType: 'xRelative',
expectBeforeUnconstrained: '|> line([0.5, yRel001], %)', expectBeforeUnconstrained: '|> line([0.5, yRel001], %)',
expectAfterUnconstrained: 'line([xRel001, yRel001], %)', expectAfterUnconstrained: 'line([xRel001, yRel001], %)',
expectFinal: '|> line([0.5, yRel001], %)', expectFinal: '|> line([0.5, yRel001], %)',
ang: -45, ang: ang + 180,
}) })
const angledLine = await u.getBoundingBox(`[data-overlay-index="1"]`) const angledLine = await u.getBoundingBox(`[data-overlay-index="1"]`)
ang = await u.getAngle(`[data-overlay-index="1"]`)
console.log('angledLine1') console.log('angledLine1')
await clickConstrained({ await clickConstrained({
hoverPos: { x: angledLine.x - 10, y: angledLine.y }, hoverPos: { x: angledLine.x, y: angledLine.y },
constraintType: 'angle', constraintType: 'angle',
expectBeforeUnconstrained: expectBeforeUnconstrained:
'angledLine({ angle: 3 + 0, length: 32 + 0 }, %)', 'angledLine({ angle: 3 + 0, length: 32 + 0 }, %)',
expectAfterUnconstrained: 'angledLine({ angle: 3, length: 32 + 0 }, %)', expectAfterUnconstrained: 'angledLine({ angle: 3, length: 32 + 0 }, %)',
expectFinal: 'angledLine({ angle: angle001, length: 32 + 0 }, %)', expectFinal: 'angledLine({ angle: angle001, length: 32 + 0 }, %)',
ang: ang + 180,
}) })
console.log('angledLine2') console.log('angledLine2')
await clickConstrained({ await clickConstrained({
hoverPos: { x: angledLine.x - 10, y: angledLine.y }, hoverPos: { x: angledLine.x, y: angledLine.y },
constraintType: 'length', constraintType: 'length',
expectBeforeUnconstrained: expectBeforeUnconstrained:
'angledLine({ angle: angle001, length: 32 + 0 }, %)', 'angledLine({ angle: angle001, length: 32 + 0 }, %)',
expectAfterUnconstrained: expectAfterUnconstrained:
'angledLine({ angle: angle001, length: 32 }, %)', 'angledLine({ angle: angle001, length: 32 }, %)',
expectFinal: 'angledLine({ angle: angle001, length: len001 }, %)', expectFinal: 'angledLine({ angle: angle001, length: len001 }, %)',
ang: ang + 180,
}) })
await page.mouse.move(700, 250) await page.mouse.move(700, 250)
@ -3223,36 +3209,39 @@ test.describe('Testing segment overlays', () => {
} }
await page.waitForTimeout(200) await page.waitForTimeout(200)
const lineTo = await u.getBoundingBox(`[data-overlay-index="2"]`) let lineTo = await u.getBoundingBox(`[data-overlay-index="2"]`)
ang = await u.getAngle(`[data-overlay-index="2"]`)
console.log('lineTo1') console.log('lineTo1')
await clickConstrained({ await clickConstrained({
hoverPos: { x: lineTo.x, y: lineTo.y + 21 }, hoverPos: { x: lineTo.x, y: lineTo.y },
constraintType: 'yAbsolute', constraintType: 'yAbsolute',
expectBeforeUnconstrained: 'lineTo([33, 11.5 + 0], %)', expectBeforeUnconstrained: 'lineTo([5 + 33, 20 + 11.5 + 0], %)',
expectAfterUnconstrained: 'lineTo([33, 11.5], %)', expectAfterUnconstrained: 'lineTo([5 + 33, 31.5], %)',
expectFinal: 'lineTo([33, yAbs001], %)', expectFinal: 'lineTo([5 + 33, yAbs001], %)',
steps: 8, steps: 8,
ang: 55, ang: ang + 180,
}) })
console.log('lineTo2') console.log('lineTo2')
await clickUnconstrained({ await clickConstrained({
hoverPos: { x: lineTo.x, y: lineTo.y + 25 }, hoverPos: { x: lineTo.x, y: lineTo.y },
constraintType: 'xAbsolute', constraintType: 'xAbsolute',
expectBeforeUnconstrained: 'lineTo([33, yAbs001], %)', expectBeforeUnconstrained: 'lineTo([5 + 33, yAbs001], %)',
expectAfterUnconstrained: 'lineTo([xAbs001, yAbs001], %)', expectAfterUnconstrained: 'lineTo([38, yAbs001], %)',
expectFinal: 'lineTo([33, yAbs001], %)', expectFinal: 'lineTo([xAbs001, yAbs001], %)',
steps: 8, steps: 8,
ang: ang + 180,
}) })
const xLineTo = await u.getBoundingBox(`[data-overlay-index="3"]`) const xLineTo = await u.getBoundingBox(`[data-overlay-index="3"]`)
ang = await u.getAngle(`[data-overlay-index="3"]`)
console.log('xlineTo1') console.log('xlineTo1')
await clickConstrained({ await clickConstrained({
hoverPos: { x: xLineTo.x + 15, y: xLineTo.y }, hoverPos: { x: xLineTo.x, y: xLineTo.y },
constraintType: 'xAbsolute', constraintType: 'xAbsolute',
expectBeforeUnconstrained: 'xLineTo(9 - 5, %)', expectBeforeUnconstrained: 'xLineTo(5 + 9 - 5, %)',
expectAfterUnconstrained: 'xLineTo(4, %)', expectAfterUnconstrained: 'xLineTo(9, %)',
expectFinal: 'xLineTo(xAbs002, %)', expectFinal: 'xLineTo(xAbs002, %)',
ang: -45, ang: ang + 180,
steps: 8, steps: 8,
}) })
}) })
@ -3297,7 +3286,7 @@ const part001 = startSketchOn('XZ')
await expect(page.getByTestId('segment-overlay')).toHaveCount(8) await expect(page.getByTestId('segment-overlay')).toHaveCount(8)
const clickUnconstrained = _clickUnconstrained(page) const clickUnconstrained = _clickUnconstrained(page, u)
await page.mouse.move(700, 250) await page.mouse.move(700, 250)
for (let i = 0; i < 7; i++) { for (let i = 0; i < 7; i++) {
@ -3307,26 +3296,31 @@ const part001 = startSketchOn('XZ')
await page.waitForTimeout(300) await page.waitForTimeout(300)
let ang = 0
const yLineTo = await u.getBoundingBox(`[data-overlay-index="4"]`) const yLineTo = await u.getBoundingBox(`[data-overlay-index="4"]`)
ang = await u.getAngle(`[data-overlay-index="4"]`)
console.log('ylineTo1') console.log('ylineTo1')
await clickUnconstrained({ await clickUnconstrained({
hoverPos: { x: yLineTo.x, y: yLineTo.y - 30 }, hoverPos: { x: yLineTo.x, y: yLineTo.y },
constraintType: 'yAbsolute', constraintType: 'yAbsolute',
expectBeforeUnconstrained: "yLineTo(-10.77, %, 'a')", expectBeforeUnconstrained: "yLineTo(-10.77, %, 'a')",
expectAfterUnconstrained: "yLineTo(yAbs002, %, 'a')", expectAfterUnconstrained: "yLineTo(yAbs002, %, 'a')",
expectFinal: "yLineTo(-10.77, %, 'a')", expectFinal: "yLineTo(-10.77, %, 'a')",
ang: ang + 180,
}) })
const xLine = await u.getBoundingBox(`[data-overlay-index="5"]`) const xLine = await u.getBoundingBox(`[data-overlay-index="5"]`)
ang = await u.getAngle(`[data-overlay-index="5"]`)
console.log('xline') console.log('xline')
await clickUnconstrained({ await clickUnconstrained({
hoverPos: { x: xLine.x - 25, y: xLine.y }, hoverPos: { x: xLine.x, y: xLine.y },
constraintType: 'xRelative', constraintType: 'xRelative',
expectBeforeUnconstrained: 'xLine(26.04, %)', expectBeforeUnconstrained: 'xLine(26.04, %)',
expectAfterUnconstrained: 'xLine(xRel002, %)', expectAfterUnconstrained: 'xLine(xRel002, %)',
expectFinal: 'xLine(26.04, %)', expectFinal: 'xLine(26.04, %)',
steps: 10, steps: 10,
ang: 50, ang: ang + 180,
}) })
}) })
test('for segments [yLine, angledLineOfXLength, angledLineOfYLength]', async ({ test('for segments [yLine, angledLineOfXLength, angledLineOfYLength]', async ({
@ -3366,6 +3360,7 @@ const part001 = startSketchOn('XZ')
await u.openDebugPanel() await u.openDebugPanel()
await u.expectCmdLog('[data-message-type="execution-done"]') await u.expectCmdLog('[data-message-type="execution-done"]')
await u.closeDebugPanel() await u.closeDebugPanel()
await page.waitForTimeout(500)
await page.getByText('xLineTo(9 - 5, %)').click() await page.getByText('xLineTo(9 - 5, %)').click()
await page.waitForTimeout(100) await page.waitForTimeout(100)
@ -3374,10 +3369,13 @@ const part001 = startSketchOn('XZ')
await expect(page.getByTestId('segment-overlay')).toHaveCount(13) await expect(page.getByTestId('segment-overlay')).toHaveCount(13)
const clickUnconstrained = _clickUnconstrained(page) const clickUnconstrained = _clickUnconstrained(page, u)
const clickConstrained = _clickConstrained(page) const clickConstrained = _clickConstrained(page, u)
let ang = 0
const yLine = await u.getBoundingBox(`[data-overlay-index="6"]`) const yLine = await u.getBoundingBox(`[data-overlay-index="6"]`)
ang = await u.getAngle(`[data-overlay-index="6"]`)
console.log('yline1') console.log('yline1')
await clickConstrained({ await clickConstrained({
hoverPos: { x: yLine.x, y: yLine.y + 20 }, hoverPos: { x: yLine.x, y: yLine.y + 20 },
@ -3385,11 +3383,13 @@ const part001 = startSketchOn('XZ')
expectBeforeUnconstrained: 'yLine(21.14 + 0, %)', expectBeforeUnconstrained: 'yLine(21.14 + 0, %)',
expectAfterUnconstrained: 'yLine(21.14, %)', expectAfterUnconstrained: 'yLine(21.14, %)',
expectFinal: 'yLine(yRel001, %)', expectFinal: 'yLine(yRel001, %)',
ang: ang + 180,
}) })
const angledLineOfXLength = await u.getBoundingBox( const angledLineOfXLength = await u.getBoundingBox(
`[data-overlay-index="7"]` `[data-overlay-index="7"]`
) )
ang = await u.getAngle(`[data-overlay-index="7"]`)
console.log('angledLineOfXLength1') console.log('angledLineOfXLength1')
await clickConstrained({ await clickConstrained({
hoverPos: { x: angledLineOfXLength.x + 20, y: angledLineOfXLength.y }, hoverPos: { x: angledLineOfXLength.x + 20, y: angledLineOfXLength.y },
@ -3400,6 +3400,7 @@ const part001 = startSketchOn('XZ')
'angledLineOfXLength({ angle: -179, length: 23.14 }, %)', 'angledLineOfXLength({ angle: -179, length: 23.14 }, %)',
expectFinal: expectFinal:
'angledLineOfXLength({ angle: angle001, length: 23.14 }, %)', 'angledLineOfXLength({ angle: angle001, length: 23.14 }, %)',
ang: ang + 180,
}) })
console.log('angledLineOfXLength2') console.log('angledLineOfXLength2')
await clickUnconstrained({ await clickUnconstrained({
@ -3412,11 +3413,13 @@ const part001 = startSketchOn('XZ')
expectFinal: expectFinal:
'angledLineOfXLength({ angle: angle001, length: 23.14 }, %)', 'angledLineOfXLength({ angle: angle001, length: 23.14 }, %)',
steps: 7, steps: 7,
ang: ang + 180,
}) })
const angledLineOfYLength = await u.getBoundingBox( const angledLineOfYLength = await u.getBoundingBox(
`[data-overlay-index="8"]` `[data-overlay-index="8"]`
) )
ang = await u.getAngle(`[data-overlay-index="8"]`)
console.log('angledLineOfYLength1') console.log('angledLineOfYLength1')
await clickUnconstrained({ await clickUnconstrained({
hoverPos: { x: angledLineOfYLength.x, y: angledLineOfYLength.y - 20 }, hoverPos: { x: angledLineOfYLength.x, y: angledLineOfYLength.y - 20 },
@ -3426,7 +3429,7 @@ const part001 = startSketchOn('XZ')
expectAfterUnconstrained: expectAfterUnconstrained:
'angledLineOfYLength({ angle: angle002, length: 19 + 0 }, %)', 'angledLineOfYLength({ angle: angle002, length: 19 + 0 }, %)',
expectFinal: 'angledLineOfYLength({ angle: -91, length: 19 + 0 }, %)', expectFinal: 'angledLineOfYLength({ angle: -91, length: 19 + 0 }, %)',
ang: 135, ang: ang + 180,
steps: 6, steps: 6,
}) })
console.log('angledLineOfYLength2') console.log('angledLineOfYLength2')
@ -3438,14 +3441,13 @@ const part001 = startSketchOn('XZ')
expectAfterUnconstrained: expectAfterUnconstrained:
'angledLineOfYLength({ angle: -91, length: 19 }, %)', 'angledLineOfYLength({ angle: -91, length: 19 }, %)',
expectFinal: 'angledLineOfYLength({ angle: -91, length: yRel002 }, %)', expectFinal: 'angledLineOfYLength({ angle: -91, length: yRel002 }, %)',
ang: -45, ang: ang + 180,
steps: 7, steps: 7,
}) })
}) })
test('for segments [angledLineToX, angledLineToY, angledLineThatIntersects]', async ({ test('for segments [angledLineToX, angledLineToY, angledLineThatIntersects]', async ({
page, page,
}) => { }) => {
test.skip(process.platform !== 'darwin', 'too flakey on ubuntu')
await page.addInitScript(async () => { await page.addInitScript(async () => {
localStorage.setItem( localStorage.setItem(
'persistCode', 'persistCode',
@ -3488,17 +3490,21 @@ const part001 = startSketchOn('XZ')
await expect(page.getByTestId('segment-overlay')).toHaveCount(13) await expect(page.getByTestId('segment-overlay')).toHaveCount(13)
const clickUnconstrained = _clickUnconstrained(page) const clickUnconstrained = _clickUnconstrained(page, u)
const clickConstrained = _clickConstrained(page) const clickConstrained = _clickConstrained(page, u)
let ang = 0
const angledLineToX = await u.getBoundingBox(`[data-overlay-index="9"]`) const angledLineToX = await u.getBoundingBox(`[data-overlay-index="9"]`)
ang = await u.getAngle(`[data-overlay-index="9"]`)
console.log('angledLineToX') console.log('angledLineToX')
await clickConstrained({ await clickConstrained({
hoverPos: { x: angledLineToX.x - 20, y: angledLineToX.y }, hoverPos: { x: angledLineToX.x, y: angledLineToX.y },
constraintType: 'angle', constraintType: 'angle',
expectBeforeUnconstrained: 'angledLineToX({ angle: 3 + 0, to: 26 }, %)', expectBeforeUnconstrained: 'angledLineToX({ angle: 3 + 0, to: 26 }, %)',
expectAfterUnconstrained: 'angledLineToX({ angle: 3, to: 26 }, %)', expectAfterUnconstrained: 'angledLineToX({ angle: 3, to: 26 }, %)',
expectFinal: 'angledLineToX({ angle: angle001, to: 26 }, %)', expectFinal: 'angledLineToX({ angle: angle001, to: 26 }, %)',
ang: ang + 180,
}) })
console.log('angledLineToX2') console.log('angledLineToX2')
await clickUnconstrained({ await clickUnconstrained({
@ -3509,12 +3515,14 @@ const part001 = startSketchOn('XZ')
expectAfterUnconstrained: expectAfterUnconstrained:
'angledLineToX({ angle: angle001, to: xAbs001 }, %)', 'angledLineToX({ angle: angle001, to: xAbs001 }, %)',
expectFinal: 'angledLineToX({ angle: angle001, to: 26 }, %)', expectFinal: 'angledLineToX({ angle: angle001, to: 26 }, %)',
ang: ang + 180,
}) })
const angledLineToY = await u.getBoundingBox(`[data-overlay-index="10"]`) const angledLineToY = await u.getBoundingBox(`[data-overlay-index="10"]`)
ang = await u.getAngle(`[data-overlay-index="10"]`)
console.log('angledLineToY') console.log('angledLineToY')
await clickUnconstrained({ await clickUnconstrained({
hoverPos: { x: angledLineToY.x, y: angledLineToY.y + 20 }, hoverPos: { x: angledLineToY.x, y: angledLineToY.y },
constraintType: 'angle', constraintType: 'angle',
expectBeforeUnconstrained: expectBeforeUnconstrained:
'angledLineToY({ angle: 89, to: 9.14 + 0 }, %)', 'angledLineToY({ angle: 89, to: 9.14 + 0 }, %)',
@ -3522,7 +3530,7 @@ const part001 = startSketchOn('XZ')
'angledLineToY({ angle: angle002, to: 9.14 + 0 }, %)', 'angledLineToY({ angle: angle002, to: 9.14 + 0 }, %)',
expectFinal: 'angledLineToY({ angle: 89, to: 9.14 + 0 }, %)', expectFinal: 'angledLineToY({ angle: 89, to: 9.14 + 0 }, %)',
steps: process.platform === 'darwin' ? 8 : 9, steps: process.platform === 'darwin' ? 8 : 9,
ang: 135, ang: ang + 180,
}) })
console.log('angledLineToY2') console.log('angledLineToY2')
await clickConstrained({ await clickConstrained({
@ -3532,12 +3540,13 @@ const part001 = startSketchOn('XZ')
'angledLineToY({ angle: 89, to: 9.14 + 0 }, %)', 'angledLineToY({ angle: 89, to: 9.14 + 0 }, %)',
expectAfterUnconstrained: 'angledLineToY({ angle: 89, to: 9.14 }, %)', expectAfterUnconstrained: 'angledLineToY({ angle: 89, to: 9.14 }, %)',
expectFinal: 'angledLineToY({ angle: 89, to: yAbs001 }, %)', expectFinal: 'angledLineToY({ angle: 89, to: yAbs001 }, %)',
ang: 135, ang: ang + 180,
}) })
const angledLineThatIntersects = await u.getBoundingBox( const angledLineThatIntersects = await u.getBoundingBox(
`[data-overlay-index="11"]` `[data-overlay-index="11"]`
) )
ang = await u.getAngle(`[data-overlay-index="11"]`)
console.log('angledLineThatIntersects') console.log('angledLineThatIntersects')
await clickUnconstrained({ await clickUnconstrained({
hoverPos: { hoverPos: {
@ -3560,7 +3569,7 @@ const part001 = startSketchOn('XZ')
offset: 9, offset: 9,
intersectTag: 'a' intersectTag: 'a'
}, %)`, }, %)`,
ang: -45, ang: ang + 180,
}) })
console.log('angledLineThatIntersects2') console.log('angledLineThatIntersects2')
await clickUnconstrained({ await clickUnconstrained({
@ -3584,7 +3593,7 @@ const part001 = startSketchOn('XZ')
offset: 9, offset: 9,
intersectTag: 'a' intersectTag: 'a'
}, %)`, }, %)`,
ang: -25, ang: ang + 180,
}) })
}) })
test('for segment [tangentialArcTo]', async ({ page }) => { test('for segment [tangentialArcTo]', async ({ page }) => {
@ -3630,30 +3639,31 @@ const part001 = startSketchOn('XZ')
await expect(page.getByTestId('segment-overlay')).toHaveCount(13) await expect(page.getByTestId('segment-overlay')).toHaveCount(13)
const clickUnconstrained = _clickUnconstrained(page) const clickUnconstrained = _clickUnconstrained(page, u)
const clickConstrained = _clickConstrained(page) const clickConstrained = _clickConstrained(page, u)
const tangentialArcTo = await u.getBoundingBox( const tangentialArcTo = await u.getBoundingBox(
`[data-overlay-index="12"]` `[data-overlay-index="12"]`
) )
let ang = await u.getAngle(`[data-overlay-index="12"]`)
console.log('tangentialArcTo') console.log('tangentialArcTo')
await clickConstrained({ await clickConstrained({
hoverPos: { x: tangentialArcTo.x - 10, y: tangentialArcTo.y + 20 }, hoverPos: { x: tangentialArcTo.x, y: tangentialArcTo.y },
constraintType: 'xAbsolute', constraintType: 'xAbsolute',
expectBeforeUnconstrained: 'tangentialArcTo([3.14 + 13, -3.14], %)', expectBeforeUnconstrained: 'tangentialArcTo([3.14 + 13, -3.14], %)',
expectAfterUnconstrained: 'tangentialArcTo([16.14, -3.14], %)', expectAfterUnconstrained: 'tangentialArcTo([16.14, -3.14], %)',
expectFinal: 'tangentialArcTo([xAbs001, -3.14], %)', expectFinal: 'tangentialArcTo([xAbs001, -3.14], %)',
ang: -45, ang: ang + 180,
steps: 6, steps: 6,
}) })
console.log('tangentialArcTo2') console.log('tangentialArcTo2')
await clickUnconstrained({ await clickUnconstrained({
hoverPos: { x: tangentialArcTo.x - 10, y: tangentialArcTo.y + 20 }, hoverPos: { x: tangentialArcTo.x, y: tangentialArcTo.y },
constraintType: 'yAbsolute', constraintType: 'yAbsolute',
expectBeforeUnconstrained: 'tangentialArcTo([xAbs001, -3.14], %)', expectBeforeUnconstrained: 'tangentialArcTo([xAbs001, -3.14], %)',
expectAfterUnconstrained: 'tangentialArcTo([xAbs001, yAbs001], %)', expectAfterUnconstrained: 'tangentialArcTo([xAbs001, yAbs001], %)',
expectFinal: 'tangentialArcTo([xAbs001, -3.14], %)', expectFinal: 'tangentialArcTo([xAbs001, -3.14], %)',
ang: -135, ang: ang + 180,
steps: 10, steps: 10,
}) })
}) })
@ -3748,25 +3758,7 @@ const part001 = startSketchOn('XZ')
steps: 6, steps: 6,
}) })
segmentToDelete = await getOverlayByIndex(0) segmentToDelete = await getOverlayByIndex(11)
await deleteSegmentSequence({
hoverPos: { x: segmentToDelete.x, y: segmentToDelete.y - 20 },
codeToBeDeleted: 'line([0.5, -14 + 0], %)',
stdLibFnName: 'line',
ang: -45,
})
segmentToDelete = await getOverlayByIndex(0)
await deleteSegmentSequence({
hoverPos: { x: segmentToDelete.x - 20, y: segmentToDelete.y },
codeToBeDeleted: 'angledLine({ angle: 3 + 0, length: 32 + 0 }, %)',
stdLibFnName: 'angledLine',
ang: 135,
})
await page.waitForTimeout(200)
segmentToDelete = await getOverlayByIndex(9)
await deleteSegmentSequence({ await deleteSegmentSequence({
hoverPos: { x: segmentToDelete.x + 10, y: segmentToDelete.y }, hoverPos: { x: segmentToDelete.x + 10, y: segmentToDelete.y },
codeToBeDeleted: `angledLineThatIntersects({ codeToBeDeleted: `angledLineThatIntersects({
@ -3779,21 +3771,21 @@ const part001 = startSketchOn('XZ')
steps: 7, steps: 7,
}) })
segmentToDelete = await getOverlayByIndex(8) segmentToDelete = await getOverlayByIndex(10)
await deleteSegmentSequence({ await deleteSegmentSequence({
hoverPos: { x: segmentToDelete.x + 10, y: segmentToDelete.y }, hoverPos: { x: segmentToDelete.x + 10, y: segmentToDelete.y },
codeToBeDeleted: 'angledLineToY({ angle: 89, to: 9.14 + 0 }, %)', codeToBeDeleted: 'angledLineToY({ angle: 89, to: 9.14 + 0 }, %)',
stdLibFnName: 'angledLineToY', stdLibFnName: 'angledLineToY',
}) })
segmentToDelete = await getOverlayByIndex(7) segmentToDelete = await getOverlayByIndex(9)
await deleteSegmentSequence({ await deleteSegmentSequence({
hoverPos: { x: segmentToDelete.x - 10, y: segmentToDelete.y }, hoverPos: { x: segmentToDelete.x - 10, y: segmentToDelete.y },
codeToBeDeleted: 'angledLineToX({ angle: 3 + 0, to: 26 }, %)', codeToBeDeleted: 'angledLineToX({ angle: 3 + 0, to: 26 }, %)',
stdLibFnName: 'angledLineToX', stdLibFnName: 'angledLineToX',
}) })
segmentToDelete = await getOverlayByIndex(6) segmentToDelete = await getOverlayByIndex(8)
await deleteSegmentSequence({ await deleteSegmentSequence({
hoverPos: { x: segmentToDelete.x, y: segmentToDelete.y - 10 }, hoverPos: { x: segmentToDelete.x, y: segmentToDelete.y - 10 },
codeToBeDeleted: codeToBeDeleted:
@ -3801,7 +3793,7 @@ const part001 = startSketchOn('XZ')
stdLibFnName: 'angledLineOfYLength', stdLibFnName: 'angledLineOfYLength',
}) })
segmentToDelete = await getOverlayByIndex(5) segmentToDelete = await getOverlayByIndex(7)
await deleteSegmentSequence({ await deleteSegmentSequence({
hoverPos: { x: segmentToDelete.x + 10, y: segmentToDelete.y }, hoverPos: { x: segmentToDelete.x + 10, y: segmentToDelete.y },
codeToBeDeleted: codeToBeDeleted:
@ -3809,42 +3801,36 @@ const part001 = startSketchOn('XZ')
stdLibFnName: 'angledLineOfXLength', stdLibFnName: 'angledLineOfXLength',
}) })
segmentToDelete = await getOverlayByIndex(4) segmentToDelete = await getOverlayByIndex(6)
await deleteSegmentSequence({ await deleteSegmentSequence({
hoverPos: { x: segmentToDelete.x, y: segmentToDelete.y + 10 }, hoverPos: { x: segmentToDelete.x, y: segmentToDelete.y + 10 },
codeToBeDeleted: 'yLine(21.14 + 0, %)', codeToBeDeleted: 'yLine(21.14 + 0, %)',
stdLibFnName: 'yLine', stdLibFnName: 'yLine',
}) })
segmentToDelete = await getOverlayByIndex(3) segmentToDelete = await getOverlayByIndex(5)
await deleteSegmentSequence({ await deleteSegmentSequence({
hoverPos: { x: segmentToDelete.x - 10, y: segmentToDelete.y }, hoverPos: { x: segmentToDelete.x - 10, y: segmentToDelete.y },
codeToBeDeleted: 'xLine(26.04, %)', codeToBeDeleted: 'xLine(26.04, %)',
stdLibFnName: 'xLine', stdLibFnName: 'xLine',
}) })
segmentToDelete = await getOverlayByIndex(2) segmentToDelete = await getOverlayByIndex(4)
await deleteSegmentSequence({ await deleteSegmentSequence({
hoverPos: { x: segmentToDelete.x, y: segmentToDelete.y - 10 }, hoverPos: { x: segmentToDelete.x, y: segmentToDelete.y - 10 },
codeToBeDeleted: "yLineTo(-10.77, %, 'a')", codeToBeDeleted: "yLineTo(-10.77, %, 'a')",
stdLibFnName: 'yLineTo', stdLibFnName: 'yLineTo',
}) })
segmentToDelete = await getOverlayByIndex(1) segmentToDelete = await getOverlayByIndex(3)
await deleteSegmentSequence({ await deleteSegmentSequence({
hoverPos: { x: segmentToDelete.x + 10, y: segmentToDelete.y }, hoverPos: { x: segmentToDelete.x + 10, y: segmentToDelete.y },
codeToBeDeleted: 'xLineTo(9 - 5, %)', codeToBeDeleted: 'xLineTo(9 - 5, %)',
stdLibFnName: 'xLineTo', stdLibFnName: 'xLineTo',
}) })
for (let i = 0; i < 15; i++) { // Not sure why this is diff. from the others - Kurt, ideas?
await page.mouse.wheel(0, 100) segmentToDelete = await getOverlayByIndex(2)
await page.waitForTimeout(25)
}
await page.waitForTimeout(200)
segmentToDelete = await getOverlayByIndex(0)
const hoverPos = { x: segmentToDelete.x - 10, y: segmentToDelete.y + 10 } const hoverPos = { x: segmentToDelete.x - 10, y: segmentToDelete.y + 10 }
await expect(page.getByText('Added variable')).not.toBeVisible() await expect(page.getByText('Added variable')).not.toBeVisible()
const [x, y] = [ const [x, y] = [
@ -3863,6 +3849,24 @@ const part001 = startSketchOn('XZ')
await expect(page.locator('.cm-content')).not.toContainText( await expect(page.locator('.cm-content')).not.toContainText(
codeToBeDeleted codeToBeDeleted
) )
segmentToDelete = await getOverlayByIndex(1)
await deleteSegmentSequence({
hoverPos: { x: segmentToDelete.x - 20, y: segmentToDelete.y },
codeToBeDeleted: 'angledLine({ angle: 3 + 0, length: 32 + 0 }, %)',
stdLibFnName: 'angledLine',
ang: 135,
})
segmentToDelete = await getOverlayByIndex(0)
await deleteSegmentSequence({
hoverPos: { x: segmentToDelete.x, y: segmentToDelete.y - 20 },
codeToBeDeleted: 'line([0.5, -14 + 0], %)',
stdLibFnName: 'line',
ang: -45,
})
await page.waitForTimeout(200)
}) })
}) })
test.describe('Testing delete with dependent segments', () => { test.describe('Testing delete with dependent segments', () => {
@ -4158,6 +4162,11 @@ test('simulate network down and network little widget', async ({ page }) => {
await page.goto('/') await page.goto('/')
await u.waitForAuthSkipAppStart() await u.waitForAuthSkipAppStart()
// This is how we wait until the stream is online
await expect(
page.getByRole('button', { name: 'Start Sketch' })
).not.toBeDisabled({ timeout: 15000 })
const networkWidget = page.locator('[data-testid="network-toggle"]') const networkWidget = page.locator('[data-testid="network-toggle"]')
await expect(networkWidget).toBeVisible() await expect(networkWidget).toBeVisible()
await networkWidget.hover() await networkWidget.hover()
@ -4165,7 +4174,7 @@ test('simulate network down and network little widget', async ({ page }) => {
const networkPopover = page.locator('[data-testid="network-popover"]') const networkPopover = page.locator('[data-testid="network-popover"]')
await expect(networkPopover).not.toBeVisible() await expect(networkPopover).not.toBeVisible()
// Expect the network to be up // (First check) Expect the network to be up
await expect(page.getByText('Network Health (Connected)')).toBeVisible() await expect(page.getByText('Network Health (Connected)')).toBeVisible()
// Click the network widget // Click the network widget
@ -4209,7 +4218,11 @@ test('simulate network down and network little widget', async ({ page }) => {
uploadThroughput: -1, uploadThroughput: -1,
}) })
// Expect the network to be up await expect(
page.getByRole('button', { name: 'Start Sketch' })
).not.toBeDisabled({ timeout: 15000 })
// (Second check) expect the network to be up
await expect(page.getByText('Network Health (Connected)')).toBeVisible() await expect(page.getByText('Network Health (Connected)')).toBeVisible()
}) })
@ -4223,8 +4236,7 @@ test('Engine disconnect & reconnect in sketch mode', async ({ page }) => {
await expect( await expect(
page.getByRole('button', { name: 'Start Sketch' }) page.getByRole('button', { name: 'Start Sketch' })
).not.toBeDisabled() ).not.toBeDisabled({ timeout: 15000 })
await expect(page.getByRole('button', { name: 'Start Sketch' })).toBeVisible()
// click on "Start Sketch" button // click on "Start Sketch" button
await u.clearCommandLogs() await u.clearCommandLogs()
@ -4287,6 +4299,10 @@ test('Engine disconnect & reconnect in sketch mode', async ({ page }) => {
}) })
// Wait for the app to be ready for use // Wait for the app to be ready for use
await expect(
page.getByRole('button', { name: 'Start Sketch' })
).not.toBeDisabled({ timeout: 15000 })
// Expect the network to be up // Expect the network to be up
await expect(page.getByText('Network Health (Connected)')).toBeVisible() await expect(page.getByText('Network Health (Connected)')).toBeVisible()
@ -4314,15 +4330,15 @@ test('Engine disconnect & reconnect in sketch mode', async ({ page }) => {
.toHaveText(`const part001 = startSketchOn('XZ') .toHaveText(`const part001 = startSketchOn('XZ')
|> startProfileAt(${commonPoints.startAt}, %) |> startProfileAt(${commonPoints.startAt}, %)
|> line([${commonPoints.num1}, 0], %) |> line([${commonPoints.num1}, 0], %)
|> line([-11.59, 11.1], %)`) |> line([-11.64, 11.11], %)`)
await page.waitForTimeout(100) await page.waitForTimeout(100)
await page.mouse.click(startXPx, 500 - PUR * 20) await page.mouse.click(startXPx, 500 - PUR * 20)
await expect(page.locator('.cm-content')) await expect(page.locator('.cm-content'))
.toHaveText(`const part001 = startSketchOn('XZ') .toHaveText(`const part001 = startSketchOn('XZ')
|> startProfileAt(${commonPoints.startAt}, %) |> startProfileAt(${commonPoints.startAt}, %)
|> line([${commonPoints.num1}, 0], %) |> line([${commonPoints.num1}, 0], %)
|> line([-11.59, 11.1], %) |> line([-11.64, 11.11], %)
|> line([-6.61, 0], %)`) |> line([-6.56, 0], %)`)
// Unequip line tool // Unequip line tool
await page.keyboard.press('Escape') await page.keyboard.press('Escape')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 45 KiB

View File

@ -102,6 +102,29 @@ export async function getUtils(page: Page) {
const cdpSession = const cdpSession =
browserType !== 'chromium' ? null : await page.context().newCDPSession(page) browserType !== 'chromium' ? null : await page.context().newCDPSession(page)
let click00rCenter = { x: 0, y: 0 }
const click00 = (x: number, y: number) =>
page.mouse.click(click00rCenter.x + x, click00rCenter.y + y)
let click00rLastPos = { x: 0, y: 0 }
// The way we truncate is kinda odd apparently, so we need this function
// "[k]itty[c]ad round"
const kcRound = (n: number) => Math.trunc(n * 100) / 100
// To translate between screen and engine ("[U]nit") coordinates
// NOTE: these pretty much can't be perfect because of screen scaling.
// Handle on a case-by-case.
const toU = (x: number, y: number) => [
kcRound(x * 0.0854),
kcRound(-y * 0.0854), // Y is inverted in our coordinate system
]
// Turn the array into a string with specific formatting
const fromUToString = (xy: number[]) => `[${xy[0]}, ${xy[1]}]`
// Combine because used often
const toSU = (xy: number[]) => fromUToString(toU(xy[0], xy[1]))
return { return {
waitForAuthSkipAppStart: () => waitForPageLoad(page), waitForAuthSkipAppStart: () => waitForPageLoad(page),
removeCurrentCode: () => removeCurrentCode(page), removeCurrentCode: () => removeCurrentCode(page),
@ -145,11 +168,15 @@ export async function getUtils(page: Page) {
y: bbox.y - angleYOffset, y: bbox.y - angleYOffset,
} }
}, },
getAngle: async (locator: string) => {
const overlay = page.locator(locator)
return Number(await overlay.getAttribute('data-overlay-angle'))
},
getBoundingBox: async (locator: string) => getBoundingBox: async (locator: string) =>
page page
.locator(locator) .locator(locator)
.boundingBox() .boundingBox()
.then((box) => ({ x: box?.x || 0, y: box?.y || 0 })), .then((box) => ({ ...box, x: box?.x || 0, y: box?.y || 0 })),
doAndWaitForCmd: async ( doAndWaitForCmd: async (
fn: () => Promise<void>, fn: () => Promise<void>,
commandType: string, commandType: string,
@ -217,6 +244,51 @@ export async function getUtils(page: Page) {
cdpSession?.send('Network.emulateNetworkConditions', networkOptions) cdpSession?.send('Network.emulateNetworkConditions', networkOptions)
}, },
expectCodeToBe: async (str: string) => {
await expect(page.locator('.cm-content')).toHaveText(str)
await page.waitForTimeout(100)
},
click00rSetCenter: (x: number, y: number) => {
click00rCenter = { x, y }
},
click00r: (x?: number, y?: number) => {
// reset relative coordinates when anything is undefined
if (x === undefined || y === undefined) {
click00rLastPos.x = 0
click00rLastPos.y = 0
return
}
const ret = click00(click00rLastPos.x + x, click00rLastPos.y + y)
click00rLastPos.x += x
click00rLastPos.y += y
// Returns the new absolute coordinate if you need it.
return ret.then(() => [click00rLastPos.x, click00rLastPos.y])
},
toSU,
wiggleMove: async (
x: number,
y: number,
steps: number,
dist: number,
ang: number,
amplitude: number,
freq: number
) => {
const tau = Math.PI * 2
const deg = tau / 360
const step = dist / steps
for (let i = 0, j = 0; i < dist; i += step, j += 1) {
const y1 = Math.sin((tau / steps) * j * freq) * amplitude
const [x2, y2] = [
Math.cos(-ang * deg) * i - Math.sin(-ang * deg) * y1,
Math.sin(-ang * deg) * i + Math.cos(-ang * deg) * y1,
]
const [xr, yr] = [x2, y2]
await page.mouse.move(x + xr, y + yr, { steps: 2 })
}
},
} }
} }

View File

@ -10,7 +10,7 @@
"@fortawesome/react-fontawesome": "^0.2.0", "@fortawesome/react-fontawesome": "^0.2.0",
"@headlessui/react": "^1.7.19", "@headlessui/react": "^1.7.19",
"@headlessui/tailwindcss": "^0.2.0", "@headlessui/tailwindcss": "^0.2.0",
"@kittycad/lib": "^0.0.63", "@kittycad/lib": "^0.0.64",
"@lezer/javascript": "^1.4.9", "@lezer/javascript": "^1.4.9",
"@open-rpc/client-js": "^1.8.1", "@open-rpc/client-js": "^1.8.1",
"@react-hook/resize-observer": "^2.0.1", "@react-hook/resize-observer": "^2.0.1",
@ -95,7 +95,8 @@
"lint": "eslint --fix src", "lint": "eslint --fix src",
"bump-jsons": "echo \"$(jq --arg v \"$VERSION\" '.version=$v' package.json --indent 2)\" > package.json && echo \"$(jq --arg v \"$VERSION\" '.version=$v' src-tauri/tauri.conf.json --indent 2)\" > src-tauri/tauri.conf.json", "bump-jsons": "echo \"$(jq --arg v \"$VERSION\" '.version=$v' package.json --indent 2)\" > package.json && echo \"$(jq --arg v \"$VERSION\" '.version=$v' src-tauri/tauri.conf.json --indent 2)\" > src-tauri/tauri.conf.json",
"postinstall": "yarn xstate:typegen", "postinstall": "yarn xstate:typegen",
"xstate:typegen": "yarn xstate typegen \"src/**/*.ts?(x)\"" "xstate:typegen": "yarn xstate typegen \"src/**/*.ts?(x)\"",
"make:dev": "make dev"
}, },
"prettier": { "prettier": {
"trailingComma": "es5", "trailingComma": "es5",

View File

@ -18,7 +18,7 @@ export default defineConfig({
/* Retry on CI only */ /* Retry on CI only */
retries: process.env.CI ? 3 : 0, retries: process.env.CI ? 3 : 0,
/* Opt out of parallel tests on CI. */ /* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : 1, workers: process.env.CI ? 2 : 1,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html', reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
@ -72,7 +72,7 @@ export default defineConfig({
/* Run your local dev server before starting the tests */ /* Run your local dev server before starting the tests */
webServer: { webServer: {
command: 'yarn serve', command: 'yarn start',
// url: 'http://127.0.0.1:3000', // url: 'http://127.0.0.1:3000',
reuseExistingServer: !process.env.CI, reuseExistingServer: !process.env.CI,
}, },

View File

@ -12,6 +12,8 @@ import SignIn from './routes/SignIn'
import { Auth } from './Auth' import { Auth } from './Auth'
import { isTauri } from './lib/isTauri' import { isTauri } from './lib/isTauri'
import Home from './routes/Home' import Home from './routes/Home'
import { NetworkContext } from './hooks/useNetworkContext'
import { useNetworkStatus } from './hooks/useNetworkStatus'
import makeUrlPathRelative from './lib/makeUrlPathRelative' import makeUrlPathRelative from './lib/makeUrlPathRelative'
import DownloadAppBanner from 'components/DownloadAppBanner' import DownloadAppBanner from 'components/DownloadAppBanner'
import { WasmErrBanner } from 'components/WasmErrBanner' import { WasmErrBanner } from 'components/WasmErrBanner'
@ -155,5 +157,11 @@ const router = createBrowserRouter([
* @returns RouterProvider * @returns RouterProvider
*/ */
export const Router = () => { export const Router = () => {
return <RouterProvider router={router} /> const networkStatus = useNetworkStatus()
return (
<NetworkContext.Provider value={networkStatus as any}>
<RouterProvider router={router} />
</NetworkContext.Provider>
)
} }

View File

@ -3,13 +3,11 @@ import { isCursorInSketchCommandRange } from 'lang/util'
import { engineCommandManager, kclManager } from 'lib/singletons' import { engineCommandManager, kclManager } from 'lib/singletons'
import { useModelingContext } from 'hooks/useModelingContext' import { useModelingContext } from 'hooks/useModelingContext'
import { useCommandsContext } from 'hooks/useCommandsContext' import { useCommandsContext } from 'hooks/useCommandsContext'
import { useNetworkContext } from 'hooks/useNetworkContext'
import { NetworkHealthState } from 'hooks/useNetworkStatus'
import { ActionButton } from 'components/ActionButton' import { ActionButton } from 'components/ActionButton'
import { isSingleCursorInPipe } from 'lang/queryAst' import { isSingleCursorInPipe } from 'lang/queryAst'
import { useKclContext } from 'lang/KclProvider' import { useKclContext } from 'lang/KclProvider'
import {
NetworkHealthState,
useNetworkStatus,
} from 'components/NetworkHealthIndicator'
import { useStore } from 'useStore' import { useStore } from 'useStore'
import { ActionButtonDropdown } from 'components/ActionButtonDropdown' import { ActionButtonDropdown } from 'components/ActionButtonDropdown'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
@ -38,14 +36,16 @@ export function Toolbar({
}, [engineCommandManager.artifactMap, context.selectionRanges]) }, [engineCommandManager.artifactMap, context.selectionRanges])
const toolbarButtonsRef = useRef<HTMLUListElement>(null) const toolbarButtonsRef = useRef<HTMLUListElement>(null)
const { overallState } = useNetworkContext()
const { overallState } = useNetworkStatus()
const { isExecuting } = useKclContext() const { isExecuting } = useKclContext()
const { isStreamReady } = useStore((s) => ({ const { isStreamReady } = useStore((s) => ({
isStreamReady: s.isStreamReady, isStreamReady: s.isStreamReady,
})) }))
const disableAllButtons = const disableAllButtons =
overallState !== NetworkHealthState.Ok || isExecuting || !isStreamReady (overallState !== NetworkHealthState.Ok &&
overallState !== NetworkHealthState.Weak) ||
isExecuting ||
!isStreamReady
useHotkeys( useHotkeys(
'l', 'l',

View File

@ -1,14 +1,65 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import {
EngineConnectionStateType,
DisconnectingType,
EngineCommandManagerEvents,
EngineConnectionEvents,
ConnectionError,
CONNECTION_ERROR_TEXT,
} from '../lang/std/engineConnection'
import { engineCommandManager } from '../lib/singletons'
const Loading = ({ children }: React.PropsWithChildren) => { const Loading = ({ children }: React.PropsWithChildren) => {
const [hasLongLoadTime, setHasLongLoadTime] = useState(false) const [error, setError] = useState<ConnectionError>(ConnectionError.Unset)
useEffect(() => { useEffect(() => {
const onConnectionStateChange = ({ detail: state }: CustomEvent) => {
if (
(state.type !== EngineConnectionStateType.Disconnected ||
state.type !== EngineConnectionStateType.Disconnecting) &&
state.value?.type !== DisconnectingType.Error
)
return
setError(state.value.value.error)
}
const onEngineAvailable = ({ detail: engineConnection }: CustomEvent) => {
engineConnection.addEventListener(
EngineConnectionEvents.ConnectionStateChanged,
onConnectionStateChange as EventListener
)
}
engineCommandManager.addEventListener(
EngineCommandManagerEvents.EngineAvailable,
onEngineAvailable as EventListener
)
return () => {
engineCommandManager.removeEventListener(
EngineCommandManagerEvents.EngineAvailable,
onEngineAvailable as EventListener
)
engineCommandManager.engineConnection?.removeEventListener(
EngineConnectionEvents.ConnectionStateChanged,
onConnectionStateChange as EventListener
)
}
}, [])
useEffect(() => {
// Don't set long loading time if there's a more severe error
if (error > ConnectionError.LongLoadingTime) return
const timer = setTimeout(() => { const timer = setTimeout(() => {
setHasLongLoadTime(true) setError(ConnectionError.LongLoadingTime)
}, 4000) }, 4000)
return () => clearTimeout(timer) return () => clearTimeout(timer)
}, [setHasLongLoadTime]) }, [error, setError])
return ( return (
<div <div
className="body-bg flex flex-col items-center justify-center h-screen" className="body-bg flex flex-col items-center justify-center h-screen"
@ -29,10 +80,10 @@ const Loading = ({ children }: React.PropsWithChildren) => {
<p <p
className={ className={
'text-sm mt-4 text-primary/60 transition-opacity duration-500' + 'text-sm mt-4 text-primary/60 transition-opacity duration-500' +
(hasLongLoadTime ? ' opacity-100' : ' opacity-0') (error !== ConnectionError.Unset ? ' opacity-100' : ' opacity-0')
} }
> >
Loading is taking longer than expected. {CONNECTION_ERROR_TEXT[error]}
</p> </p>
</div> </div>
) )

View File

@ -13,7 +13,6 @@ import { LanguageSupport } from '@codemirror/language'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { paths } from 'lib/paths' import { paths } from 'lib/paths'
import { FileEntry } from 'lib/types' import { FileEntry } from 'lib/types'
import { NetworkHealthState, useNetworkStatus } from './NetworkHealthIndicator'
import Worker from 'editor/plugins/lsp/worker.ts?worker' import Worker from 'editor/plugins/lsp/worker.ts?worker'
import { import {
LspWorkerEventType, LspWorkerEventType,
@ -23,6 +22,8 @@ import {
} from 'editor/plugins/lsp/types' } from 'editor/plugins/lsp/types'
import { wasmUrl } from 'lang/wasm' import { wasmUrl } from 'lang/wasm'
import { PROJECT_ENTRYPOINT } from 'lib/constants' import { PROJECT_ENTRYPOINT } from 'lib/constants'
import { useNetworkContext } from 'hooks/useNetworkContext'
import { NetworkHealthState } from 'hooks/useNetworkStatus'
function getWorkspaceFolders(): LSP.WorkspaceFolder[] { function getWorkspaceFolders(): LSP.WorkspaceFolder[] {
return [] return []
@ -86,7 +87,7 @@ export const LspProvider = ({ children }: { children: React.ReactNode }) => {
} = useSettingsAuthContext() } = useSettingsAuthContext()
const token = auth?.context.token const token = auth?.context.token
const navigate = useNavigate() const navigate = useNavigate()
const { overallState } = useNetworkStatus() const { overallState } = useNetworkContext()
const isNetworkOkay = overallState === NetworkHealthState.Ok const isNetworkOkay = overallState === NetworkHealthState.Ok
// So this is a bit weird, we need to initialize the lsp server and client. // So this is a bit weird, we need to initialize the lsp server and client.

View File

@ -5,8 +5,8 @@ import { CommandBarProvider } from './CommandBar/CommandBarProvider'
import { import {
NETWORK_HEALTH_TEXT, NETWORK_HEALTH_TEXT,
NetworkHealthIndicator, NetworkHealthIndicator,
NetworkHealthState,
} from './NetworkHealthIndicator' } from './NetworkHealthIndicator'
import { NetworkHealthState } from 'hooks/useNetworkStatus'
function TestWrap({ children }: { children: React.ReactNode }) { function TestWrap({ children }: { children: React.ReactNode }) {
// wrap in router and xState context // wrap in router and xState context
@ -19,6 +19,7 @@ function TestWrap({ children }: { children: React.ReactNode }) {
) )
} }
// Our Playwright tests for this are much more comprehensive.
describe('NetworkHealthIndicator tests', () => { describe('NetworkHealthIndicator tests', () => {
test('Renders the network indicator', () => { test('Renders the network indicator', () => {
render( render(
@ -29,21 +30,7 @@ describe('NetworkHealthIndicator tests', () => {
fireEvent.click(screen.getByTestId('network-toggle')) fireEvent.click(screen.getByTestId('network-toggle'))
expect(screen.getByTestId('network')).toHaveTextContent( // Starts as disconnected
NETWORK_HEALTH_TEXT[NetworkHealthState.Ok]
)
})
test('Responds to network changes', () => {
render(
<TestWrap>
<NetworkHealthIndicator />
</TestWrap>
)
fireEvent.offline(window)
fireEvent.click(screen.getByTestId('network-toggle'))
expect(screen.getByTestId('network')).toHaveTextContent( expect(screen.getByTestId('network')).toHaveTextContent(
NETWORK_HEALTH_TEXT[NetworkHealthState.Disconnected] NETWORK_HEALTH_TEXT[NetworkHealthState.Disconnected]
) )

View File

@ -1,26 +1,13 @@
import { Popover } from '@headlessui/react' import { Popover } from '@headlessui/react'
import { useEffect, useState } from 'react'
import { ActionIcon, ActionIconProps } from './ActionIcon' import { ActionIcon, ActionIconProps } from './ActionIcon'
import {
ConnectingType,
ConnectingTypeGroup,
DisconnectingType,
EngineConnectionState,
EngineConnectionStateType,
ErrorType,
initialConnectingTypeGroupState,
} from '../lang/std/engineConnection'
import { engineCommandManager } from '../lib/singletons'
import Tooltip from './Tooltip' import Tooltip from './Tooltip'
import { ConnectingTypeGroup } from '../lang/std/engineConnection'
export enum NetworkHealthState { import { useNetworkContext } from '../hooks/useNetworkContext'
Ok, import { NetworkHealthState } from '../hooks/useNetworkStatus'
Issue,
Disconnected,
}
export const NETWORK_HEALTH_TEXT: Record<NetworkHealthState, string> = { export const NETWORK_HEALTH_TEXT: Record<NetworkHealthState, string> = {
[NetworkHealthState.Ok]: 'Connected', [NetworkHealthState.Ok]: 'Connected',
[NetworkHealthState.Weak]: 'Weak',
[NetworkHealthState.Issue]: 'Problem', [NetworkHealthState.Issue]: 'Problem',
[NetworkHealthState.Disconnected]: 'Offline', [NetworkHealthState.Disconnected]: 'Offline',
} }
@ -61,6 +48,10 @@ const overallConnectionStateColor: Record<NetworkHealthState, IconColorConfig> =
icon: 'text-succeed-80 dark:text-succeed-10', icon: 'text-succeed-80 dark:text-succeed-10',
bg: 'bg-succeed-10/30 dark:bg-succeed-80/50', bg: 'bg-succeed-10/30 dark:bg-succeed-80/50',
}, },
[NetworkHealthState.Weak]: {
icon: 'text-warn-80 dark:text-warn-10',
bg: 'bg-warn-10 dark:bg-warn-80/80',
},
[NetworkHealthState.Issue]: { [NetworkHealthState.Issue]: {
icon: 'text-destroy-80 dark:text-destroy-10', icon: 'text-destroy-80 dark:text-destroy-10',
bg: 'bg-destroy-10 dark:bg-destroy-80/80', bg: 'bg-destroy-10 dark:bg-destroy-80/80',
@ -76,125 +67,11 @@ const overallConnectionStateIcon: Record<
ActionIconProps['icon'] ActionIconProps['icon']
> = { > = {
[NetworkHealthState.Ok]: 'network', [NetworkHealthState.Ok]: 'network',
[NetworkHealthState.Weak]: 'network',
[NetworkHealthState.Issue]: 'networkCrossedOut', [NetworkHealthState.Issue]: 'networkCrossedOut',
[NetworkHealthState.Disconnected]: 'networkCrossedOut', [NetworkHealthState.Disconnected]: 'networkCrossedOut',
} }
export function useNetworkStatus() {
const [steps, setSteps] = useState(initialConnectingTypeGroupState)
const [internetConnected, setInternetConnected] = useState<boolean>(true)
const [overallState, setOverallState] = useState<NetworkHealthState>(
NetworkHealthState.Ok
)
const [hasCopied, setHasCopied] = useState<boolean>(false)
const [error, setError] = useState<ErrorType | undefined>(undefined)
const issues: Record<ConnectingTypeGroup, boolean> = {
[ConnectingTypeGroup.WebSocket]: steps[ConnectingTypeGroup.WebSocket].some(
(a: [ConnectingType, boolean | undefined]) => a[1] === false
),
[ConnectingTypeGroup.ICE]: steps[ConnectingTypeGroup.ICE].some(
(a: [ConnectingType, boolean | undefined]) => a[1] === false
),
[ConnectingTypeGroup.WebRTC]: steps[ConnectingTypeGroup.WebRTC].some(
(a: [ConnectingType, boolean | undefined]) => a[1] === false
),
}
const hasIssues: boolean =
issues[ConnectingTypeGroup.WebSocket] ||
issues[ConnectingTypeGroup.ICE] ||
issues[ConnectingTypeGroup.WebRTC]
useEffect(() => {
setOverallState(
!internetConnected
? NetworkHealthState.Disconnected
: hasIssues
? NetworkHealthState.Issue
: NetworkHealthState.Ok
)
}, [hasIssues, internetConnected])
useEffect(() => {
const onlineCallback = () => {
setSteps(initialConnectingTypeGroupState)
setInternetConnected(true)
}
const offlineCallback = () => {
setInternetConnected(false)
}
window.addEventListener('online', onlineCallback)
window.addEventListener('offline', offlineCallback)
return () => {
window.removeEventListener('online', onlineCallback)
window.removeEventListener('offline', offlineCallback)
}
}, [])
useEffect(() => {
engineCommandManager.onConnectionStateChange(
(engineConnectionState: EngineConnectionState) => {
let hasSetAStep = false
if (
engineConnectionState.type === EngineConnectionStateType.Connecting
) {
const groups = Object.values(steps)
for (let group of groups) {
for (let step of group) {
if (step[0] !== engineConnectionState.value.type) continue
step[1] = true
hasSetAStep = true
}
}
}
if (
engineConnectionState.type === EngineConnectionStateType.Disconnecting
) {
const groups = Object.values(steps)
for (let group of groups) {
for (let step of group) {
if (
engineConnectionState.value.type === DisconnectingType.Error
) {
if (
engineConnectionState.value.value.lastConnectingValue
?.type === step[0]
) {
step[1] = false
hasSetAStep = true
}
}
}
if (engineConnectionState.value.type === DisconnectingType.Error) {
setError(engineConnectionState.value.value)
}
}
}
if (hasSetAStep) {
setSteps(steps)
}
}
)
}, [])
return {
hasIssues,
overallState,
internetConnected,
steps,
issues,
error,
setHasCopied,
hasCopied,
}
}
export const NetworkHealthIndicator = () => { export const NetworkHealthIndicator = () => {
const { const {
hasIssues, hasIssues,
@ -205,7 +82,7 @@ export const NetworkHealthIndicator = () => {
error, error,
setHasCopied, setHasCopied,
hasCopied, hasCopied,
} = useNetworkStatus() } = useNetworkContext()
return ( return (
<Popover className="relative"> <Popover className="relative">
@ -259,18 +136,18 @@ export const NetworkHealthIndicator = () => {
size="lg" size="lg"
icon={ icon={
hasIssueToIcon[ hasIssueToIcon[
issues[name as ConnectingTypeGroup].toString() String(issues[name as ConnectingTypeGroup])
] ]
} }
iconClassName={ iconClassName={
hasIssueToIconColors[ hasIssueToIconColors[
issues[name as ConnectingTypeGroup].toString() String(issues[name as ConnectingTypeGroup])
].icon ].icon
} }
bgClassName={ bgClassName={
'rounded-sm ' + 'rounded-sm ' +
hasIssueToIconColors[ hasIssueToIconColors[
issues[name as ConnectingTypeGroup].toString() String(issues[name as ConnectingTypeGroup])
].bg ].bg
} }
/> />

View File

@ -4,8 +4,9 @@ import { getNormalisedCoordinates } from '../lib/utils'
import Loading from './Loading' import Loading from './Loading'
import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext' import { useSettingsAuthContext } from 'hooks/useSettingsAuthContext'
import { useModelingContext } from 'hooks/useModelingContext' import { useModelingContext } from 'hooks/useModelingContext'
import { useNetworkContext } from 'hooks/useNetworkContext'
import { NetworkHealthState } from 'hooks/useNetworkStatus'
import { ClientSideScene } from 'clientSideScene/ClientSideSceneComp' import { ClientSideScene } from 'clientSideScene/ClientSideSceneComp'
import { NetworkHealthState, useNetworkStatus } from './NetworkHealthIndicator'
import { butName } from 'lib/cameraControls' import { butName } from 'lib/cameraControls'
import { sendSelectEventToEngine } from 'lib/selections' import { sendSelectEventToEngine } from 'lib/selections'
@ -28,8 +29,11 @@ export const Stream = ({ className = '' }: { className?: string }) => {
})) }))
const { settings } = useSettingsAuthContext() const { settings } = useSettingsAuthContext()
const { state } = useModelingContext() const { state } = useModelingContext()
const { overallState } = useNetworkStatus() const { overallState } = useNetworkContext()
const isNetworkOkay = overallState === NetworkHealthState.Ok
const isNetworkOkay =
overallState === NetworkHealthState.Ok ||
overallState === NetworkHealthState.Weak
useEffect(() => { useEffect(() => {
if ( if (
@ -43,6 +47,7 @@ export const Stream = ({ className = '' }: { className?: string }) => {
}, [mediaStream]) }, [mediaStream])
const handleMouseDown: MouseEventHandler<HTMLDivElement> = (e) => { const handleMouseDown: MouseEventHandler<HTMLDivElement> = (e) => {
if (!isNetworkOkay) return
if (!videoRef.current) return if (!videoRef.current) return
if (state.matches('Sketch')) return if (state.matches('Sketch')) return
if (state.matches('Sketch no face')) return if (state.matches('Sketch no face')) return
@ -58,6 +63,7 @@ export const Stream = ({ className = '' }: { className?: string }) => {
} }
const handleMouseUp: MouseEventHandler<HTMLDivElement> = (e) => { const handleMouseUp: MouseEventHandler<HTMLDivElement> = (e) => {
if (!isNetworkOkay) return
if (!videoRef.current) return if (!videoRef.current) return
setButtonDownInStream(undefined) setButtonDownInStream(undefined)
if (state.matches('Sketch')) return if (state.matches('Sketch')) return
@ -72,6 +78,7 @@ export const Stream = ({ className = '' }: { className?: string }) => {
} }
const handleMouseMove: MouseEventHandler<HTMLVideoElement> = (e) => { const handleMouseMove: MouseEventHandler<HTMLVideoElement> = (e) => {
if (!isNetworkOkay) return
if (state.matches('Sketch')) return if (state.matches('Sketch')) return
if (state.matches('Sketch no face')) return if (state.matches('Sketch no face')) return
if (!clickCoords) return if (!clickCoords) return
@ -112,7 +119,7 @@ export const Stream = ({ className = '' }: { className?: string }) => {
{!isNetworkOkay && !isLoading && ( {!isNetworkOkay && !isLoading && (
<div className="text-center absolute inset-0"> <div className="text-center absolute inset-0">
<Loading> <Loading>
<span data-testid="loading-stream">Stream disconnected</span> <span data-testid="loading-stream">Stream disconnected...</span>
</Loading> </Loading>
</div> </div>
)} )}

View File

@ -474,19 +474,13 @@ const completionRequester = (client: LanguageServerClient) => {
} }
export const copilotPlugin = (options: LanguageServerOptions): Extension => { export const copilotPlugin = (options: LanguageServerOptions): Extension => {
let plugin: LanguageServerPlugin | null = null
return [ return [
documentUri.of(options.documentUri), documentUri.of(options.documentUri),
languageId.of('kcl'), languageId.of('kcl'),
workspaceFolders.of(options.workspaceFolders), workspaceFolders.of(options.workspaceFolders),
ViewPlugin.define( ViewPlugin.define(
(view) => (view) =>
(plugin = new LanguageServerPlugin( new LanguageServerPlugin(options.client, view, options.allowHTMLContent)
options.client,
view,
options.allowHTMLContent
))
), ),
completionDecoration, completionDecoration,
Prec.highest(completionPlugin(options.client)), Prec.highest(completionPlugin(options.client)),

View File

@ -0,0 +1,25 @@
import { createContext, useContext } from 'react'
import {
ConnectingTypeGroup,
initialConnectingTypeGroupState,
} from '../lang/std/engineConnection'
import { NetworkHealthState } from './useNetworkStatus'
export const NetworkContext = createContext({
hasIssues: undefined,
overallState: NetworkHealthState.Disconnected,
internetConnected: true,
steps: structuredClone(initialConnectingTypeGroupState),
issues: {
[ConnectingTypeGroup.WebSocket]: undefined,
[ConnectingTypeGroup.ICE]: undefined,
[ConnectingTypeGroup.WebRTC]: undefined,
},
error: undefined,
setHasCopied: (b: boolean) => {},
hasCopied: false,
pingPongHealth: undefined,
})
export const useNetworkContext = () => {
return useContext(NetworkContext)
}

View File

@ -0,0 +1,213 @@
import { useEffect, useState } from 'react'
import {
ConnectingType,
ConnectingTypeGroup,
DisconnectingType,
EngineCommandManagerEvents,
EngineConnectionEvents,
EngineConnectionStateType,
ErrorType,
initialConnectingTypeGroupState,
} from '../lang/std/engineConnection'
import { engineCommandManager } from '../lib/singletons'
export enum NetworkHealthState {
Ok,
Weak,
Issue,
Disconnected,
}
// Must be called from one place in the application.
// We've chosen the <Router /> component for this.
export function useNetworkStatus() {
const [steps, setSteps] = useState(
structuredClone(initialConnectingTypeGroupState)
)
const [internetConnected, setInternetConnected] = useState<boolean>(true)
const [overallState, setOverallState] = useState<NetworkHealthState>(
NetworkHealthState.Disconnected
)
const [pingPongHealth, setPingPongHealth] = useState<
undefined | 'OK' | 'TIMEOUT'
>(undefined)
const [hasCopied, setHasCopied] = useState<boolean>(false)
const [error, setError] = useState<ErrorType | undefined>(undefined)
const hasIssue = (i: [ConnectingType, boolean | undefined]) =>
i[1] === undefined ? i[1] : !i[1]
const [issues, setIssues] = useState<
Record<ConnectingTypeGroup, boolean | undefined>
>({
[ConnectingTypeGroup.WebSocket]: undefined,
[ConnectingTypeGroup.ICE]: undefined,
[ConnectingTypeGroup.WebRTC]: undefined,
})
const [hasIssues, setHasIssues] = useState<boolean | undefined>(undefined)
useEffect(() => {
setOverallState(
!internetConnected
? NetworkHealthState.Disconnected
: hasIssues || hasIssues === undefined
? NetworkHealthState.Issue
: pingPongHealth === 'TIMEOUT'
? NetworkHealthState.Weak
: NetworkHealthState.Ok
)
}, [hasIssues, internetConnected, pingPongHealth])
useEffect(() => {
const onlineCallback = () => {
setInternetConnected(true)
}
const offlineCallback = () => {
setInternetConnected(false)
setSteps(structuredClone(initialConnectingTypeGroupState))
}
window.addEventListener('online', onlineCallback)
window.addEventListener('offline', offlineCallback)
return () => {
window.removeEventListener('online', onlineCallback)
window.removeEventListener('offline', offlineCallback)
}
}, [])
useEffect(() => {
const issues = {
[ConnectingTypeGroup.WebSocket]: steps[
ConnectingTypeGroup.WebSocket
].reduce(
(acc: boolean | undefined, a) =>
acc === true || acc === undefined ? acc : hasIssue(a),
false
),
[ConnectingTypeGroup.ICE]: steps[ConnectingTypeGroup.ICE].reduce(
(acc: boolean | undefined, a) =>
acc === true || acc === undefined ? acc : hasIssue(a),
false
),
[ConnectingTypeGroup.WebRTC]: steps[ConnectingTypeGroup.WebRTC].reduce(
(acc: boolean | undefined, a) =>
acc === true || acc === undefined ? acc : hasIssue(a),
false
),
}
setIssues(issues)
}, [steps])
useEffect(() => {
setHasIssues(
issues[ConnectingTypeGroup.WebSocket] ||
issues[ConnectingTypeGroup.ICE] ||
issues[ConnectingTypeGroup.WebRTC]
)
}, [issues])
useEffect(() => {
const onPingPongChange = ({ detail: state }: CustomEvent) => {
setPingPongHealth(state)
}
const onConnectionStateChange = ({
detail: engineConnectionState,
}: CustomEvent) => {
setSteps((steps) => {
let nextSteps = structuredClone(steps)
if (
engineConnectionState.type === EngineConnectionStateType.Connecting
) {
const groups = Object.values(nextSteps)
for (let group of groups) {
for (let step of group) {
if (step[0] !== engineConnectionState.value.type) continue
step[1] = true
}
}
}
if (
engineConnectionState.type === EngineConnectionStateType.Disconnecting
) {
const groups = Object.values(nextSteps)
for (let group of groups) {
for (let step of group) {
if (
engineConnectionState.value.type === DisconnectingType.Error
) {
if (
engineConnectionState.value.value.lastConnectingValue
?.type === step[0]
) {
step[1] = false
}
}
}
if (engineConnectionState.value.type === DisconnectingType.Error) {
setError(engineConnectionState.value.value)
}
}
}
// Reset the state of all steps if we have disconnected.
if (
engineConnectionState.type === EngineConnectionStateType.Disconnected
) {
return structuredClone(initialConnectingTypeGroupState)
}
return nextSteps
})
}
const onEngineAvailable = ({ detail: engineConnection }: CustomEvent) => {
engineConnection.addEventListener(
EngineConnectionEvents.PingPongChanged,
onPingPongChange as EventListener
)
engineConnection.addEventListener(
EngineConnectionEvents.ConnectionStateChanged,
onConnectionStateChange as EventListener
)
}
engineCommandManager.addEventListener(
EngineCommandManagerEvents.EngineAvailable,
onEngineAvailable as EventListener
)
return () => {
engineCommandManager.removeEventListener(
EngineCommandManagerEvents.EngineAvailable,
onEngineAvailable as EventListener
)
// When the component is unmounted these should be assigned, but it's possible
// the component mounts and unmounts before engine is available.
engineCommandManager.engineConnection?.addEventListener(
EngineConnectionEvents.PingPongChanged,
onPingPongChange as EventListener
)
engineCommandManager.engineConnection?.addEventListener(
EngineConnectionEvents.ConnectionStateChanged,
onConnectionStateChange as EventListener
)
}
}, [])
return {
hasIssues,
overallState,
internetConnected,
steps,
issues,
error,
setHasCopied,
hasCopied,
pingPongHealth,
}
}

View File

@ -43,7 +43,7 @@ export function useSetupEngineManager(
engineCommandManager.pool = settings.pool engineCommandManager.pool = settings.pool
} }
useLayoutEffect(() => { const startEngineInstance = () => {
// Load the engine command manager once with the initial width and height, // Load the engine command manager once with the initial width and height,
// then we do not want to reload it. // then we do not want to reload it.
const { width: quadWidth, height: quadHeight } = getDimensions( const { width: quadWidth, height: quadHeight } = getDimensions(
@ -73,7 +73,12 @@ export function useSetupEngineManager(
}) })
hasSetNonZeroDimensions.current = true hasSetNonZeroDimensions.current = true
} }
}, [streamRef?.current?.offsetWidth, streamRef?.current?.offsetHeight]) }
useLayoutEffect(startEngineInstance, [
streamRef?.current?.offsetWidth,
streamRef?.current?.offsetHeight,
])
useEffect(() => { useEffect(() => {
const handleResize = deferExecution(() => { const handleResize = deferExecution(() => {
@ -96,8 +101,20 @@ export function useSetupEngineManager(
} }
}, 500) }, 500)
const onOnline = () => {
startEngineInstance()
}
const onOffline = () => {
engineCommandManager.tearDown()
}
window.addEventListener('online', onOnline)
window.addEventListener('offline', onOffline)
window.addEventListener('resize', handleResize) window.addEventListener('resize', handleResize)
return () => { return () => {
window.removeEventListener('online', onOnline)
window.removeEventListener('offline', onOffline)
window.removeEventListener('resize', handleResize) window.removeEventListener('resize', handleResize)
} }
}, []) }, [])

View File

@ -7,12 +7,10 @@ import { authMachine } from 'machines/authMachine'
import { settingsMachine } from 'machines/settingsMachine' import { settingsMachine } from 'machines/settingsMachine'
import { homeMachine } from 'machines/homeMachine' import { homeMachine } from 'machines/homeMachine'
import { Command, CommandSetConfig, CommandSetSchema } from 'lib/commandTypes' import { Command, CommandSetConfig, CommandSetSchema } from 'lib/commandTypes'
import {
NetworkHealthState,
useNetworkStatus,
} from 'components/NetworkHealthIndicator'
import { useKclContext } from 'lang/KclProvider' import { useKclContext } from 'lang/KclProvider'
import { useStore } from 'useStore' import { useStore } from 'useStore'
import { useNetworkContext } from 'hooks/useNetworkContext'
import { NetworkHealthState } from 'hooks/useNetworkStatus'
// This might not be necessary, AnyStateMachine from xstate is working // This might not be necessary, AnyStateMachine from xstate is working
export type AllMachines = export type AllMachines =
@ -47,7 +45,7 @@ export default function useStateMachineCommands<
onCancel, onCancel,
}: UseStateMachineCommandsArgs<T, S>) { }: UseStateMachineCommandsArgs<T, S>) {
const { commandBarSend } = useCommandsContext() const { commandBarSend } = useCommandsContext()
const { overallState } = useNetworkStatus() const { overallState } = useNetworkContext()
const { isExecuting } = useKclContext() const { isExecuting } = useKclContext()
const { isStreamReady } = useStore((s) => ({ const { isStreamReady } = useStore((s) => ({
isStreamReady: s.isStreamReady, isStreamReady: s.isStreamReady,
@ -55,7 +53,10 @@ export default function useStateMachineCommands<
useEffect(() => { useEffect(() => {
const disableAllButtons = const disableAllButtons =
overallState !== NetworkHealthState.Ok || isExecuting || !isStreamReady (overallState !== NetworkHealthState.Ok &&
overallState !== NetworkHealthState.Weak) ||
isExecuting ||
!isStreamReady
const newCommands = state.nextEvents const newCommands = state.nextEvents
.filter((_) => !allCommandsRequireNetwork || !disableAllButtons) .filter((_) => !allCommandsRequireNetwork || !disableAllButtons)
.filter((e) => !['done.', 'error.'].some((n) => e.includes(n))) .filter((e) => !['done.', 'error.'].some((n) => e.includes(n)))

View File

@ -1,5 +1,5 @@
import { PathToNode, Program, SourceRange } from 'lang/wasm' import { PathToNode, Program, SourceRange } from 'lang/wasm'
import { VITE_KC_API_WS_MODELING_URL, VITE_KC_CONNECTION_TIMEOUT_MS } from 'env' import { VITE_KC_API_WS_MODELING_URL } from 'env'
import { Models } from '@kittycad/lib' import { Models } from '@kittycad/lib'
import { exportSave } from 'lib/exportSave' import { exportSave } from 'lib/exportSave'
import { uuidv4 } from 'lib/utils' import { uuidv4 } from 'lib/utils'
@ -9,6 +9,9 @@ import { DefaultPlanes } from 'wasm-lib/kcl/bindings/DefaultPlanes'
let lastMessage = '' let lastMessage = ''
// TODO(paultag): This ought to be tweakable.
const pingIntervalMs = 10000
interface CommandInfo { interface CommandInfo {
commandType: CommandTypes commandType: CommandTypes
range: SourceRange range: SourceRange
@ -26,6 +29,12 @@ interface CommandInfo {
} }
} }
function isHighlightSetEntity_type(
data: any
): data is Models['HighlightSetEntity_type'] {
return data.entity_id && data.sequence
}
type WebSocketResponse = Models['WebSocketResponse_type'] type WebSocketResponse = Models['WebSocketResponse_type']
type OkWebSocketResponseData = Models['OkWebSocketResponseData_type'] type OkWebSocketResponseData = Models['OkWebSocketResponseData_type']
@ -110,9 +119,54 @@ export enum DisconnectingType {
Quit = 'quit', Quit = 'quit',
} }
// Sorted by severity
export enum ConnectionError {
Unset = 0,
LongLoadingTime,
LostVideoStream,
ICENegotiate,
DataChannelError,
WebSocketError,
LocalDescriptionInvalid,
// These are more severe than protocol errors because they don't even allow
// the program to do any protocol messages in the first place if they occur.
MissingAuthToken,
BadAuthToken,
TooManyConnections,
// An unknown error is the most severe because it has not been classified
// or encountered before.
Unknown,
}
export const CONNECTION_ERROR_TEXT: Record<ConnectionError, string> = {
[ConnectionError.Unset]: '',
[ConnectionError.LongLoadingTime]:
'Loading is taking longer than expected...',
[ConnectionError.LostVideoStream]:
'Lost connection to video stream... Reconnecting...',
[ConnectionError.ICENegotiate]: 'ICE negotiation failed.',
[ConnectionError.DataChannelError]: 'The data channel signaled an error.',
[ConnectionError.WebSocketError]: 'The websocket signaled an error.',
[ConnectionError.LocalDescriptionInvalid]:
'The local description is invalid.',
[ConnectionError.MissingAuthToken]:
'Your authorization token is missing; please login again.',
[ConnectionError.BadAuthToken]:
'Your authorization token is invalid; please login again.',
[ConnectionError.TooManyConnections]: 'There are too many connections.',
[ConnectionError.Unknown]:
'An unexpected error occurred. Please report this to us.',
}
export interface ErrorType { export interface ErrorType {
// We may not necessary have an error to assign. // The error we've encountered.
error?: Error error: ConnectionError
// Additional context.
context?: any
// We assign this in the state setter because we may have not failed at // We assign this in the state setter because we may have not failed at
// a Connecting state, which we check for there. // a Connecting state, which we check for there.
@ -127,7 +181,7 @@ export type DisconnectingValue =
// These are ordered by the expected sequence. // These are ordered by the expected sequence.
export enum ConnectingType { export enum ConnectingType {
WebSocketConnecting = 'websocket-connecting', WebSocketConnecting = 'websocket-connecting',
WebSocketEstablished = 'websocket-established', WebSocketOpen = 'websocket-open',
PeerConnectionCreated = 'peer-connection-created', PeerConnectionCreated = 'peer-connection-created',
ICEServersSet = 'ice-servers-set', ICEServersSet = 'ice-servers-set',
SetLocalDescription = 'set-local-description', SetLocalDescription = 'set-local-description',
@ -154,7 +208,7 @@ export const initialConnectingTypeGroupState: Record<
> = { > = {
[ConnectingTypeGroup.WebSocket]: [ [ConnectingTypeGroup.WebSocket]: [
[ConnectingType.WebSocketConnecting, undefined], [ConnectingType.WebSocketConnecting, undefined],
[ConnectingType.WebSocketEstablished, undefined], [ConnectingType.WebSocketOpen, undefined],
], ],
[ConnectingTypeGroup.ICE]: [ [ConnectingTypeGroup.ICE]: [
[ConnectingType.PeerConnectionCreated, undefined], [ConnectingType.PeerConnectionCreated, undefined],
@ -176,7 +230,7 @@ export const initialConnectingTypeGroupState: Record<
export type ConnectingValue = export type ConnectingValue =
| State<ConnectingType.WebSocketConnecting, void> | State<ConnectingType.WebSocketConnecting, void>
| State<ConnectingType.WebSocketEstablished, void> | State<ConnectingType.WebSocketOpen, void>
| State<ConnectingType.PeerConnectionCreated, void> | State<ConnectingType.PeerConnectionCreated, void>
| State<ConnectingType.ICEServersSet, void> | State<ConnectingType.ICEServersSet, void>
| State<ConnectingType.SetLocalDescription, void> | State<ConnectingType.SetLocalDescription, void>
@ -197,12 +251,28 @@ export type EngineConnectionState =
| State<EngineConnectionStateType.Disconnecting, DisconnectingValue> | State<EngineConnectionStateType.Disconnecting, DisconnectingValue>
| State<EngineConnectionStateType.Disconnected, void> | State<EngineConnectionStateType.Disconnected, void>
/** export type PingPongState = 'OK' | 'TIMEOUT'
* EngineConnection encapsulates the connection(s) to the Engine
* for the EngineCommandManager; namely, the underlying WebSocket export enum EngineConnectionEvents {
* and WebRTC connections. // Fires for each ping-pong success or failure.
*/ PingPongChanged = 'ping-pong-changed', // (state: PingPongState) => void
class EngineConnection {
// For now, this is only used by the NetworkHealthIndicator.
// We can eventually use it for more, but one step at a time.
ConnectionStateChanged = 'connection-state-changed', // (state: EngineConnectionState) => void
// These are used for the EngineCommandManager and were created
// before onConnectionStateChange existed.
ConnectionStarted = 'connection-started', // (engineConnection: EngineConnection) => void
Opened = 'opened', // (engineConnection: EngineConnection) => void
Closed = 'closed', // (engineConnection: EngineConnection) => void
NewTrack = 'new-track', // (track: NewTrackArgs) => void
}
// EngineConnection encapsulates the connection(s) to the Engine
// for the EngineCommandManager; namely, the underlying WebSocket
// and WebRTC connections.
class EngineConnection extends EventTarget {
websocket?: WebSocket websocket?: WebSocket
pc?: RTCPeerConnection pc?: RTCPeerConnection
unreliableDataChannel?: RTCDataChannel unreliableDataChannel?: RTCDataChannel
@ -222,12 +292,13 @@ class EngineConnection {
if (next.type === EngineConnectionStateType.Disconnecting) { if (next.type === EngineConnectionStateType.Disconnecting) {
const sub = next.value const sub = next.value
if (sub.type === DisconnectingType.Error) { if (sub.type === DisconnectingType.Error) {
console.log(sub)
// Record the last step we failed at. // Record the last step we failed at.
// (Check the current state that we're about to override that // (Check the current state that we're about to override that
// it was a Connecting state.) // it was a Connecting state.)
console.log(sub)
if (this._state.type === EngineConnectionStateType.Connecting) { if (this._state.type === EngineConnectionStateType.Connecting) {
if (!sub.value) sub.value = {} if (!sub.value) sub.value = { error: ConnectionError.Unknown }
sub.value.lastConnectingValue = this._state.value sub.value.lastConnectingValue = this._state.value
} }
@ -235,7 +306,12 @@ class EngineConnection {
} }
} }
this._state = next this._state = next
this.onConnectionStateChange(this._state)
this.dispatchEvent(
new CustomEvent(EngineConnectionEvents.ConnectionStateChanged, {
detail: this._state,
})
)
} }
private failedConnTimeout: IsomorphicTimeout | null private failedConnTimeout: IsomorphicTimeout | null
@ -243,95 +319,80 @@ class EngineConnection {
readonly url: string readonly url: string
private readonly token?: string private readonly token?: string
/**For now, this is only used by the NetworkHealthIndicator. // TODO: actual type is ClientMetrics
* We can eventually use it for more, but one step at a time.
*/
private onConnectionStateChange: (state: EngineConnectionState) => void
/**
* Used for the EngineCommandManager, created before
* onConnectionStateChange existed.
*/
private onEngineConnectionOpen: (engineConnection: EngineConnection) => void
/**
* Used for the EngineCommandManager, created before
* onConnectionStateChange existed.
*/
private onConnectionStarted: (engineConnection: EngineConnection) => void
/**
* Used for the EngineCommandManager, created before
* onConnectionStateChange existed.
*/
private onClose: (engineConnection: EngineConnection) => void
/**
* Used for the EngineCommandManager, created before
* onConnectionStateChange existed.
*/
private onNewTrack: (track: NewTrackArgs) => void
/**
* @todo actual type is `ClientMetrics`
*/
public webrtcStatsCollector?: () => Promise<WebRTCClientMetrics> public webrtcStatsCollector?: () => Promise<WebRTCClientMetrics>
private engineCommandManager: EngineCommandManager private engineCommandManager: EngineCommandManager
private pingPongSpan: { ping?: Date; pong?: Date }
constructor({ constructor({
engineCommandManager, engineCommandManager,
url, url,
token, token,
onConnectionStateChange = () => {},
onNewTrack = () => {},
onEngineConnectionOpen = () => {},
onConnectionStarted = () => {},
onClose = () => {},
}: { }: {
engineCommandManager: EngineCommandManager engineCommandManager: EngineCommandManager
url: string url: string
token?: string token?: string
onConnectionStateChange?: (state: EngineConnectionState) => void
onEngineConnectionOpen?: (engineConnection: EngineConnection) => void
onConnectionStarted?: (engineConnection: EngineConnection) => void
onClose?: (engineConnection: EngineConnection) => void
onNewTrack?: (track: NewTrackArgs) => void
}) { }) {
super()
this.engineCommandManager = engineCommandManager this.engineCommandManager = engineCommandManager
this.url = url this.url = url
this.token = token this.token = token
this.failedConnTimeout = null this.failedConnTimeout = null
this.onConnectionStateChange = onConnectionStateChange
this.onEngineConnectionOpen = onEngineConnectionOpen
this.onConnectionStarted = onConnectionStarted
this.onClose = onClose this.pingPongSpan = { ping: undefined, pong: undefined }
this.onNewTrack = onNewTrack
// TODO(paultag): This ought to be tweakable.
const pingIntervalMs = 10000
// Without an interval ping, our connection will timeout. // Without an interval ping, our connection will timeout.
let pingInterval = setInterval(() => { setInterval(() => {
switch (this.state.type as EngineConnectionStateType) { switch (this.state.type as EngineConnectionStateType) {
case EngineConnectionStateType.ConnectionEstablished: case EngineConnectionStateType.ConnectionEstablished:
// If there was no reply to the last ping, report a timeout.
if (this.pingPongSpan.ping && !this.pingPongSpan.pong) {
this.dispatchEvent(
new CustomEvent(EngineConnectionEvents.PingPongChanged, {
detail: 'TIMEOUT',
})
)
// Otherwise check the time between was >= pingIntervalMs,
// and if it was, then it's bad network health.
} else if (this.pingPongSpan.ping && this.pingPongSpan.pong) {
if (
Math.abs(
this.pingPongSpan.pong.valueOf() -
this.pingPongSpan.ping.valueOf()
) >= pingIntervalMs
) {
this.dispatchEvent(
new CustomEvent(EngineConnectionEvents.PingPongChanged, {
detail: 'TIMEOUT',
})
)
} else {
this.dispatchEvent(
new CustomEvent(EngineConnectionEvents.PingPongChanged, {
detail: 'OK',
})
)
}
}
this.send({ type: 'ping' }) this.send({ type: 'ping' })
this.pingPongSpan.ping = new Date()
this.pingPongSpan.pong = undefined
break break
case EngineConnectionStateType.Disconnecting: case EngineConnectionStateType.Disconnecting:
case EngineConnectionStateType.Disconnected: case EngineConnectionStateType.Disconnected:
clearInterval(pingInterval) // Reconnect if we have disconnected.
if (!this.isConnecting()) this.connect()
break break
default: default:
if (this.isConnecting()) break
// Means we never could do an initial connection. Reconnect everything.
if (!this.pingPongSpan.ping) this.connect()
break break
} }
}, pingIntervalMs) }, pingIntervalMs)
const connectionTimeoutMs = VITE_KC_CONNECTION_TIMEOUT_MS
let connectRetryInterval = setInterval(() => {
if (this.state.type !== EngineConnectionStateType.Disconnected) return
// Only try reconnecting when completely disconnected.
clearInterval(connectRetryInterval)
console.log('Trying to reconnect')
this.connect()
}, connectionTimeoutMs)
} }
isConnecting() { isConnecting() {
@ -362,13 +423,18 @@ class EngineConnection {
return return
} }
// Information on the connect transaction
const createPeerConnection = () => { const createPeerConnection = () => {
this.pc = new RTCPeerConnection({ this.pc = new RTCPeerConnection({
bundlePolicy: 'max-bundle', bundlePolicy: 'max-bundle',
}) })
// Other parts of the application expect pc to be initialized when firing.
this.dispatchEvent(
new CustomEvent(EngineConnectionEvents.ConnectionStarted, {
detail: this,
})
)
// Data channels MUST BE specified before SDP offers because requesting // Data channels MUST BE specified before SDP offers because requesting
// them affects what our needs are! // them affects what our needs are!
const DATACHANNEL_NAME_UMC = 'unreliable_modeling_cmds' const DATACHANNEL_NAME_UMC = 'unreliable_modeling_cmds'
@ -422,7 +488,11 @@ class EngineConnection {
// dance is it safest to connect the video tracks / stream // dance is it safest to connect the video tracks / stream
case 'connected': case 'connected':
// Let the browser attach to the video stream now // Let the browser attach to the video stream now
this.onNewTrack({ conn: this, mediaStream: this.mediaStream! }) this.dispatchEvent(
new CustomEvent(EngineConnectionEvents.NewTrack, {
detail: { conn: this, mediaStream: this.mediaStream! },
})
)
break break
case 'failed': case 'failed':
this.disconnectAll() this.disconnectAll()
@ -431,9 +501,8 @@ class EngineConnection {
value: { value: {
type: DisconnectingType.Error, type: DisconnectingType.Error,
value: { value: {
error: new Error( error: ConnectionError.ICENegotiate,
'failed to negotiate ice connection; restarting' context: event,
),
}, },
}, },
} }
@ -552,7 +621,10 @@ class EngineConnection {
this.state = { type: EngineConnectionStateType.ConnectionEstablished } this.state = { type: EngineConnectionStateType.ConnectionEstablished }
this.engineCommandManager.inSequence = 1 this.engineCommandManager.inSequence = 1
this.onEngineConnectionOpen(this)
this.dispatchEvent(
new CustomEvent(EngineConnectionEvents.Opened, { detail: this })
)
}) })
this.unreliableDataChannel.addEventListener('close', (event) => { this.unreliableDataChannel.addEventListener('close', (event) => {
@ -568,7 +640,8 @@ class EngineConnection {
value: { value: {
type: DisconnectingType.Error, type: DisconnectingType.Error,
value: { value: {
error: new Error(event.toString()), error: ConnectionError.DataChannelError,
context: event,
}, },
}, },
} }
@ -606,6 +679,7 @@ class EngineConnection {
}, },
} }
const createWebSocketConnection = () => {
this.websocket = new WebSocket(this.url, []) this.websocket = new WebSocket(this.url, [])
this.websocket.binaryType = 'arraybuffer' this.websocket.binaryType = 'arraybuffer'
@ -613,7 +687,7 @@ class EngineConnection {
this.state = { this.state = {
type: EngineConnectionStateType.Connecting, type: EngineConnectionStateType.Connecting,
value: { value: {
type: ConnectingType.WebSocketEstablished, type: ConnectingType.WebSocketOpen,
}, },
} }
@ -626,6 +700,10 @@ class EngineConnection {
headers: { Authorization: `Bearer ${this.token}` }, headers: { Authorization: `Bearer ${this.token}` },
}) })
} }
// Send an initial ping
this.send({ type: 'ping' })
this.pingPongSpan.ping = new Date()
}) })
this.websocket.addEventListener('close', (event) => { this.websocket.addEventListener('close', (event) => {
@ -641,7 +719,8 @@ class EngineConnection {
value: { value: {
type: DisconnectingType.Error, type: DisconnectingType.Error,
value: { value: {
error: new Error(event.toString()), error: ConnectionError.WebSocketError,
context: event,
}, },
}, },
} }
@ -655,6 +734,8 @@ class EngineConnection {
// when assuming we're the only consumer or that all messages will // when assuming we're the only consumer or that all messages will
// be carefully formatted here. // be carefully formatted here.
console.log(event)
if (typeof event.data !== 'string') { if (typeof event.data !== 'string') {
return return
} }
@ -675,21 +756,39 @@ class EngineConnection {
`Error in response to request ${message.request_id}:\n${errorsString} `Error in response to request ${message.request_id}:\n${errorsString}
failed cmd type was ${artifactThatFailed?.commandType}` failed cmd type was ${artifactThatFailed?.commandType}`
) )
console.log(artifactThatFailed)
} else { } else {
console.error(`Error from server:\n${errorsString}`) console.error(`Error from server:\n${errorsString}`)
} }
const firstError = message?.errors[0]
if (firstError.error_code === 'auth_token_invalid') {
this.state = {
type: EngineConnectionStateType.Disconnecting,
value: {
type: DisconnectingType.Error,
value: {
error: ConnectionError.BadAuthToken,
context: firstError.message,
},
},
}
this.disconnectAll()
}
return return
} }
let resp = message.resp let resp = message.resp
// If there's no body to the response, we can bail here. // If there's no body to the response, we can bail here.
// !resp.type is usually "pong" response for our "ping"
if (!resp || !resp.type) { if (!resp || !resp.type) {
return return
} }
switch (resp.type) { switch (resp.type) {
case 'pong':
this.pingPongSpan.pong = new Date()
break
case 'ice_server_info': case 'ice_server_info':
let ice_servers = resp.data?.ice_servers let ice_servers = resp.data?.ice_servers
@ -758,10 +857,7 @@ failed cmd type was ${artifactThatFailed?.commandType}`
return this.pc?.setLocalDescription(offer).then(() => { return this.pc?.setLocalDescription(offer).then(() => {
this.send({ this.send({
type: 'sdp_offer', type: 'sdp_offer',
offer: { offer: offer as Models['RtcSessionDescription_type'],
sdp: offer.sdp || '',
type: offer.type,
},
}) })
this.state = { this.state = {
type: EngineConnectionStateType.Connecting, type: EngineConnectionStateType.Connecting,
@ -771,8 +867,7 @@ failed cmd type was ${artifactThatFailed?.commandType}`
} }
}) })
}) })
.catch((error: Error) => { .catch((err: Error) => {
console.error(error)
// The local description is invalid, so there's no point continuing. // The local description is invalid, so there's no point continuing.
this.disconnectAll() this.disconnectAll()
this.state = { this.state = {
@ -780,7 +875,8 @@ failed cmd type was ${artifactThatFailed?.commandType}`
value: { value: {
type: DisconnectingType.Error, type: DisconnectingType.Error,
value: { value: {
error, error: ConnectionError.LocalDescriptionInvalid,
context: err,
}, },
}, },
} }
@ -841,28 +937,9 @@ failed cmd type was ${artifactThatFailed?.commandType}`
break break
} }
}) })
}
const connectionTimeoutMs = VITE_KC_CONNECTION_TIMEOUT_MS createWebSocketConnection()
if (this.failedConnTimeout) {
clearTimeout(this.failedConnTimeout)
this.failedConnTimeout = null
}
this.failedConnTimeout = setTimeout(() => {
if (this.isReady()) {
return
}
this.failedConnTimeout = null
this.state = {
type: EngineConnectionStateType.Disconnecting,
value: {
type: DisconnectingType.Timeout,
},
}
this.disconnectAll()
this.finalizeIfAllConnectionsClosed()
}, connectionTimeoutMs)
this.onConnectionStarted(this)
} }
// Do not change this back to an object or any, we should only be sending the // Do not change this back to an object or any, we should only be sending the
// WebSocketRequest type! // WebSocketRequest type!
@ -914,6 +991,8 @@ export interface UnreliableSubscription<T extends UnreliableResponses['type']> {
callback: (data: Extract<UnreliableResponses, { type: T }>) => void callback: (data: Extract<UnreliableResponses, { type: T }>) => void
} }
// TODO: Should eventually be replaced with native EventTarget event system,
// as it manages events in a more familiar way to other developers.
export interface Subscription<T extends ModelTypes> { export interface Subscription<T extends ModelTypes> {
event: T event: T
callback: ( callback: (
@ -941,6 +1020,10 @@ export type CommandLog =
data: null data: null
} }
export enum EngineCommandManagerEvents {
EngineAvailable = 'engine-available',
}
/** /**
* The EngineCommandManager is the main interface to the Engine for Modeling App. * The EngineCommandManager is the main interface to the Engine for Modeling App.
* *
@ -951,7 +1034,7 @@ export type CommandLog =
* It also maintains an {@link artifactMap} that keeps track of the state of each * It also maintains an {@link artifactMap} that keeps track of the state of each
* command, and the artifacts that have been generated by those commands. * command, and the artifacts that have been generated by those commands.
*/ */
export class EngineCommandManager { export class EngineCommandManager extends EventTarget {
/** /**
* The artifactMap is a client-side representation of the commands that have been sent * The artifactMap is a client-side representation of the commands that have been sent
* to the server-side geometry engine, and the state of their resulting artifacts. * to the server-side geometry engine, and the state of their resulting artifacts.
@ -1020,10 +1103,9 @@ export class EngineCommandManager {
} }
} = {} as any } = {} as any
callbacksEngineStateConnection: ((state: EngineConnectionState) => void)[] =
[]
constructor(pool?: string) { constructor(pool?: string) {
super()
this.engineConnection = undefined this.engineConnection = undefined
this.pool = pool this.pool = pool
} }
@ -1088,14 +1170,22 @@ export class EngineCommandManager {
engineCommandManager: this, engineCommandManager: this,
url, url,
token, token,
onConnectionStateChange: (state: EngineConnectionState) => { })
for (let cb of this.callbacksEngineStateConnection) {
cb(state) this.dispatchEvent(
} new CustomEvent(EngineCommandManagerEvents.EngineAvailable, {
}, detail: this.engineConnection,
onEngineConnectionOpen: () => { })
)
this.engineConnection.addEventListener(
EngineConnectionEvents.Opened,
() => {
// Set the stream background color // Set the stream background color
this.sendSceneCommand({ // This takes RGBA values from 0-1
// So we convert from the conventional 0-255 found in Figma
void this.sendSceneCommand({
type: 'modeling_cmd_req', type: 'modeling_cmd_req',
cmd_id: uuidv4(), cmd_id: uuidv4(),
cmd: { cmd: {
@ -1125,6 +1215,24 @@ export class EngineCommandManager {
}, },
}) })
// Make the axis gizmo.
// We do this after the connection opened to avoid a race condition.
// Connected opened is the last thing that happens when the stream
// is ready.
// We also do this here because we want to ensure we create the gizmo
// and execute the code everytime the stream is restarted.
const gizmoId = uuidv4()
void this.sendSceneCommand({
type: 'modeling_cmd_req',
cmd_id: gizmoId,
cmd: {
type: 'make_axes_gizmo',
clobber: false,
// If true, axes gizmo will be placed in the corner of the screen.
// If false, it will be placed at the origin of the scene.
gizmo_mode: true,
},
})
this._camControlsCameraChange() this._camControlsCameraChange()
this.sendSceneCommand({ this.sendSceneCommand({
// CameraControls subscribes to default_camera_get_settings response events // CameraControls subscribes to default_camera_get_settings response events
@ -1141,14 +1249,58 @@ export class EngineCommandManager {
setIsStreamReady(true) setIsStreamReady(true)
await executeCode() await executeCode()
}) })
}, }
onClose: () => { )
this.engineConnection.addEventListener(
EngineConnectionEvents.Closed,
() => {
setIsStreamReady(false) setIsStreamReady(false)
}, }
onConnectionStarted: (engineConnection) => { )
this.engineConnection.addEventListener(
EngineConnectionEvents.ConnectionStarted,
({ detail: engineConnection }: any) => {
engineConnection?.pc?.addEventListener(
'datachannel',
(event: RTCDataChannelEvent) => {
let unreliableDataChannel = event.channel
unreliableDataChannel.addEventListener(
'message',
(event: MessageEvent) => {
const result: UnreliableResponses = JSON.parse(event.data)
Object.values(
this.unreliableSubscriptions[result.type] || {}
).forEach(
// TODO: There is only one response that uses the unreliable channel atm,
// highlight_set_entity, if there are more it's likely they will all have the same
// sequence logic, but I'm not sure if we use a single global sequence or a sequence
// per unreliable subscription.
(callback) => {
let data = result?.data
if (isHighlightSetEntity_type(data)) {
if (
data.sequence !== undefined &&
data.sequence > this.inSequence
) {
this.inSequence = data.sequence
callback(result)
}
}
}
)
}
)
}
)
// When the EngineConnection starts a connection, we want to register // When the EngineConnection starts a connection, we want to register
// callbacks into the WebSocket/PeerConnection. // callbacks into the WebSocket/PeerConnection.
engineConnection.websocket?.addEventListener('message', (event) => { engineConnection.websocket?.addEventListener('message', ((
event: MessageEvent
) => {
if (event.data instanceof ArrayBuffer) { if (event.data instanceof ArrayBuffer) {
// If the data is an ArrayBuffer, it's the result of an export command, // If the data is an ArrayBuffer, it's the result of an export command,
// because in all other cases we send JSON strings. But in the case of // because in all other cases we send JSON strings. But in the case of
@ -1179,23 +1331,36 @@ export class EngineCommandManager {
this.handleFailedModelingCommand(message.request_id, message) this.handleFailedModelingCommand(message.request_id, message)
} }
} }
}) }) as EventListener)
},
onNewTrack: ({ mediaStream }) => { this.engineConnection?.addEventListener(
EngineConnectionEvents.NewTrack,
(({ detail: { mediaStream } }: CustomEvent<NewTrackArgs>) => {
console.log('received track', mediaStream) console.log('received track', mediaStream)
mediaStream.getVideoTracks()[0].addEventListener('mute', () => { mediaStream.getVideoTracks()[0].addEventListener('mute', () => {
console.log('peer is not sending video to us') if (this.engineConnection) {
// this.engineConnection?.close() this.engineConnection.state = {
// this.engineConnection?.connect() type: EngineConnectionStateType.Disconnecting,
value: {
type: DisconnectingType.Error,
value: {
error: ConnectionError.LostVideoStream,
},
},
}
}
}) })
setMediaStream(mediaStream) setMediaStream(mediaStream)
}, }) as EventListener
}) )
this.engineConnection?.connect() this.engineConnection?.connect()
} }
)
}
handleResize({ handleResize({
streamWidth, streamWidth,
streamHeight, streamHeight,
@ -1443,9 +1608,6 @@ export class EngineCommandManager {
) { ) {
delete this.unreliableSubscriptions[event][id] delete this.unreliableSubscriptions[event][id]
} }
onConnectionStateChange(callback: (state: EngineConnectionState) => void) {
this.callbacksEngineStateConnection.push(callback)
}
// We make this a separate function so we can call it from wasm. // We make this a separate function so we can call it from wasm.
clearDefaultPlanes() { clearDefaultPlanes() {
this.defaultPlanes = null this.defaultPlanes = null

View File

@ -318,7 +318,6 @@ function resetAndSetEngineEntitySelectionCmds(
selections: SelectionToEngine[] selections: SelectionToEngine[]
): Models['WebSocketRequest_type'][] { ): Models['WebSocketRequest_type'][] {
if (!engineCommandManager.engineConnection?.isReady()) { if (!engineCommandManager.engineConnection?.isReady()) {
console.log('engine connection is not ready')
return [] return []
} }
return [ return [

View File

@ -1880,10 +1880,10 @@
resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz#08d6c5e20cf7e4cc02fd181c4b0c225cd31dbb60" resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz#08d6c5e20cf7e4cc02fd181c4b0c225cd31dbb60"
integrity sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA== integrity sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==
"@kittycad/lib@^0.0.63": "@kittycad/lib@^0.0.64":
version "0.0.63" version "0.0.64"
resolved "https://registry.yarnpkg.com/@kittycad/lib/-/lib-0.0.63.tgz#cc70cf1c0780543bbca6f55aae40d0904cfd45d7" resolved "https://registry.yarnpkg.com/@kittycad/lib/-/lib-0.0.64.tgz#0cea0788cd8af4a8964ddbf7152028affadcb17f"
integrity sha512-fDpGnycumT1xI/tSubRZzU9809/7s+m06w2EuJzxowgFrdIlvThnIHVf3EYvSujdFb0bHR/LZjodAw2ocXkXZw== integrity sha512-qHyvNYKbhsfR5aXLFrdKrBQ4JI+0G0v096oROD3HatJ+AIzg5H0THmI+rMnQ9L4zx4U6n1A9gLi7ZQjSsZsleg==
dependencies: dependencies:
node-fetch "3.3.2" node-fetch "3.3.2"
openapi-types "^12.0.0" openapi-types "^12.0.0"
@ -8234,16 +8234,7 @@ string-natural-compare@^3.0.1:
resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4" resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4"
integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw==
"string-width-cjs@npm:string-width@^4.2.0": "string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
dependencies:
emoji-regex "^8.0.0"
is-fullwidth-code-point "^3.0.0"
strip-ansi "^6.0.1"
string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
version "4.2.3" version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@ -8316,14 +8307,7 @@ string_decoder@~1.1.1:
dependencies: dependencies:
safe-buffer "~5.1.0" safe-buffer "~5.1.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1": "strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1" version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@ -9305,7 +9289,7 @@ workerpool@6.2.1:
resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.1.tgz#46fc150c17d826b86a008e5a4508656777e9c343" resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.1.tgz#46fc150c17d826b86a008e5a4508656777e9c343"
integrity sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw== integrity sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw==
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
version "7.0.0" version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@ -9323,15 +9307,6 @@ wrap-ansi@^6.2.0:
string-width "^4.1.0" string-width "^4.1.0"
strip-ansi "^6.0.0" strip-ansi "^6.0.0"
wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"
wrap-ansi@^8.1.0: wrap-ansi@^8.1.0:
version "8.1.0" version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"