Compare commits

...

4 Commits

Author SHA1 Message Date
c42903d2e8 Merge branch 'main' into kurt-web-app-oauth 2025-06-06 14:28:39 +10:00
d98669fb8a web app oauth 2025-06-06 13:35:11 +10:00
max
de6184c622 Add support for tag on close segment when the last sketch edge is missing (#7375)
* add test

* fix

* Update snapshots

* Update snapshots

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-06-06 11:30:24 +10:00
0bce7d3c1c move TTC capture to unit test (#7268)
* move TTC capture to unit test

* progress with artifact

* fmt

* abstract cases

* add another case

* add another test

* update snapshots with proper file names

* force to JSON

* fmt

* make jest happy

* add another example and other tweaks

* fix

* tweak

* add logs

* more logs

* strip out kcl version

* remove logs

* add comment explainer

* more comments

* more comment

* remove package-lock line
2025-06-05 21:29:20 -04:00
13 changed files with 1276 additions and 22 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

@ -11,6 +11,12 @@ const jestConfig: Config = {
"^.+\.tsx?$": ["ts-jest",{ babelConfig: true }],
},
testEnvironment: "jest-fixed-jsdom",
// Include both standard test patterns and our custom .jesttest. pattern
testMatch: [
"**/__tests__/**/*.[jt]s?(x)",
"**/?(*.)+(spec|test).[tj]s?(x)",
"**/?(*.)+(jesttest).[tj]s?(x)"
],
// TAG: paths, path, baseUrl, alias
// This is necessary to use tsconfig path aliases.
moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { prefix: '<rootDir>/../' }),

View File

@ -32,7 +32,12 @@ import {
} from '@src/lib/singletons'
import { maybeWriteToDisk } from '@src/lib/telemetry'
import type { IndexLoaderData } from '@src/lib/types'
import { engineStreamActor, useSettings, useToken } from '@src/lib/singletons'
import {
engineStreamActor,
useSettings,
useToken,
useAuthState,
} from '@src/lib/singletons'
import { EngineStreamTransition } from '@src/machines/engineStreamMachine'
import { BillingTransition } from '@src/machines/billingMachine'
import { CommandBarOpenButton } from '@src/components/CommandBarOpenButton'
@ -90,6 +95,7 @@ export function App() {
const settings = useSettings()
const authToken = useToken()
const authState = useAuthState()
useHotkeys('backspace', (e) => {
e.preventDefault()
@ -133,6 +139,7 @@ export function App() {
settings.app.onboardingStatus.default
const needsOnboarded =
!isDesktop() &&
authState.matches('loggedIn') &&
searchParams.size === 0 &&
needsToOnboard(location, onboardingStatus)
@ -152,12 +159,13 @@ export function App() {
}
)
}
}, [settings.app.onboardingStatus])
}, [settings.app.onboardingStatus, authState])
useEffect(() => {
const needsDownloadAppToast =
!isDesktop() &&
!isPlaywright() &&
authState.matches('loggedIn') &&
searchParams.size === 0 &&
!settings.app.dismissWebBanner.current
if (needsDownloadAppToast) {
@ -186,7 +194,7 @@ export function App() {
}
)
}
}, [])
}, [authState])
useEffect(() => {
const needsWasmInitFailedToast = !isDesktop() && kclManager.wasmInitFailed

View File

@ -514,6 +514,36 @@ extrude001 = extrude(sketch001, length = -15, tagEnd = $capEnd001)
|> line(endAbsolute = [profileStartX(%), profileStartY(%)])
|> close()
|> extrude(length = -15, tagEnd = $capEnd001)
|> ${edgeTreatmentType}(
${parameterName} = 3,
tags = [
getCommonEdge(faces = [seg01, capEnd001])
],
)`
await runModifyAstCloneWithEdgeTreatmentAndTag(
code,
segmentSnippets,
parameters,
expectedCode
)
}, 10_000)
it(`should add a ${edgeTreatmentType} to "close" if last segment is missing`, async () => {
const code = `sketch001 = startSketchOn(XY)
|> startProfile(at = [-10, 10])
|> line(end = [20, 0])
|> line(end = [0, -20])
|> line(end = [-20, 0])
|> close()
|> extrude(length = -15)`
const segmentSnippets = ['close()']
const expectedCode = `sketch001 = startSketchOn(XY)
|> startProfile(at = [-10, 10])
|> line(end = [20, 0])
|> line(end = [0, -20])
|> line(end = [-20, 0])
|> close(tag = $seg01)
|> extrude(length = -15, tagEnd = $capEnd001)
|> ${edgeTreatmentType}(
${parameterName} = 3,
tags = [

View File

@ -533,7 +533,10 @@ function modifyAstWithTagForSketchSegment(
if (err(segmentNode)) return segmentNode
// Check whether selection is a valid sketch segment
if (!(segmentNode.node.callee.name.name in sketchLineHelperMapKw)) {
if (
!(segmentNode.node.callee.name.name in sketchLineHelperMapKw) &&
segmentNode.node.callee.name.name !== 'close'
) {
return new Error('Selection is not a sketch segment')
}

View File

@ -1,7 +1,7 @@
import type { Binary as BSONBinary } from 'bson'
import { v4 } from 'uuid'
import type { AnyMachineSnapshot } from 'xstate'
import type { CallExpressionKw, SourceRange } from '@src/lang/wasm'
import type { CallExpressionKw, ExecState, SourceRange } from '@src/lang/wasm'
import { isDesktop } from '@src/lib/isDesktop'
import type { AsyncFn } from '@src/lib/types'
@ -522,6 +522,20 @@ export function getModuleId(sourceRange: SourceRange) {
return sourceRange[2]
}
export function getModuleIdByFileName(
fileName: string,
fileNames: ExecState['filenames']
) {
const module = Object.entries(fileNames).find(
([, moduleInfo]) =>
moduleInfo?.type === 'Local' && moduleInfo.value === fileName
)
if (module) {
return Number(module[0]) // Return the module ID
}
return -1
}
export function getInVariableCase(name: string, prefixIfDigit = 'm') {
// As of 2025-04-08, standard case for KCL variables is camelCase
const startsWithANumber = !Number.isNaN(Number(name.charAt(0)))

View File

@ -0,0 +1,198 @@
{
"make this neon green please, use #39FF14": {
"prompt": "make this neon green please, use #39FF14",
"source_ranges": [
{
"prompt": "The users main selection is the wall of a general-sweep (that is an extrusion, revolve, sweep or loft).\nThe source range though is for the original segment before it was extruded, you can add a tag to that segment in order to refer to this wall, for example \"startSketchOn(someSweepVariable, face = segmentTag)\"\nBut it's also worth bearing in mind that the user may have intended to select the sweep itself, not this individual wall, see later source ranges for more context. about the sweep",
"range": {
"start": {
"line": 6,
"column": 5
},
"end": {
"line": 6,
"column": 32
}
},
"file": "main.kcl"
},
{
"prompt": "This is the sweep's source range from the user's main selection of the end cap.",
"range": {
"start": {
"line": 10,
"column": 13
},
"end": {
"line": 10,
"column": 46
}
},
"file": "main.kcl"
}
],
"project_name": "test-project",
"files": {
"main.kcl": "import \"b.kcl\" as b\nsketch001 = startSketchOn(XZ)\nprofile001 = startProfile(sketch001, at = [57.81, 250.51])\n |> line(end = [121.13, 56.63], tag = $seg02)\n |> line(end = [83.37, -34.61], tag = $seg01)\n |> line(end = [19.66, -116.4])\n |> line(end = [-221.8, -41.69])\n |> line(endAbsolute = [profileStartX(%), profileStartY(%)])\n |> close()\nextrude001 = extrude(profile001, length = 200)\nsketch002 = startSketchOn(XZ)\n |> startProfile(at = [-73.64, -42.89])\n |> xLine(length = 173.71)\n |> line(end = [-22.12, -94.4])\n |> line(end = [-22.12, -50.4])\n |> line(end = [-22.12, -94.4])\n |> line(end = [-22.12, -50.4])\n |> xLine(length = -156.98)\n |> line(endAbsolute = [profileStartX(%), profileStartY(%)])\n |> close()\nextrude002 = extrude(sketch002, length = 50)\nb\n",
"b.kcl": "sketch003 = startSketchOn(XY)\n |> startProfile(at = [52.92, 157.81])\n |> angledLine(angle = 0, length = 176.4, tag = $rectangleSegmentA001)\n |> angledLine(\n angle = segAng(rectangleSegmentA001) - 90,\n length = 53.4,\n tag = $rectangleSegmentB001,\n )\n |> angledLine(angle = segAng(rectangleSegmentA001), length = -segLen(rectangleSegmentA001), tag = $rectangleSegmentC001)\n |> line(end = [-22.12, -50.4])\n |> line(end = [-22.12, -94.4])\n |> line(endAbsolute = [profileStartX(%), profileStartY(%)])\n |> close()\nextrude(sketch003, length = 20)\n"
},
"expectedFiles": {
"main.kcl": "import \"b.kcl\" as b\nsketch001 = startSketchOn(XZ)\nprofile001 = startProfile(sketch001, at = [57.81, 250.51])\n |> line(end = [121.13, 56.63], tag = $seg02)\n |> line(end = [83.37, -34.61], tag = $seg01)\n |> line(end = [19.66, -116.4])\n |> line(end = [-221.8, -41.69])\n |> line(endAbsolute = [profileStartX(%), profileStartY(%)])\n |> close()\nextrude001 = extrude(profile001, length = 200)\n |> appearance(color = \"#39FF14\")\nsketch002 = startSketchOn(XZ)\n |> startProfile(at = [-73.64, -42.89])\n |> xLine(length = 173.71)\n |> line(end = [-22.12, -94.4])\n |> line(end = [-22.12, -50.4])\n |> line(end = [-22.12, -94.4])\n |> line(end = [-22.12, -50.4])\n |> xLine(length = -156.98)\n |> line(endAbsolute = [profileStartX(%), profileStartY(%)])\n |> close()\nextrude002 = extrude(sketch002, length = 50)\nb\n",
"b.kcl": "sketch003 = startSketchOn(XY)\n |> startProfile(at = [52.92, 157.81])\n |> angledLine(angle = 0, length = 176.4, tag = $rectangleSegmentA001)\n |> angledLine(\n angle = segAng(rectangleSegmentA001) - 90,\n length = 53.4,\n tag = $rectangleSegmentB001,\n )\n |> angledLine(angle = segAng(rectangleSegmentA001), length = -segLen(rectangleSegmentA001), tag = $rectangleSegmentC001)\n |> line(end = [-22.12, -50.4])\n |> line(end = [-22.12, -94.4])\n |> line(endAbsolute = [profileStartX(%), profileStartY(%)])\n |> close()\nextrude(sketch003, length = 20)\n"
}
},
"Change this to red please, #ff0000": {
"prompt": "Change this to red please, #ff0000",
"source_ranges": [
{
"prompt": "The users main selection is the wall of a general-sweep (that is an extrusion, revolve, sweep or loft).\nThe source range though is for the original segment before it was extruded, you can add a tag to that segment in order to refer to this wall, for example \"startSketchOn(someSweepVariable, face = segmentTag)\"\nBut it's also worth bearing in mind that the user may have intended to select the sweep itself, not this individual wall, see later source ranges for more context. about the sweep",
"range": {
"start": {
"line": 82,
"column": 5
},
"end": {
"line": 82,
"column": 35
}
},
"file": "ball-bearing.kcl"
},
{
"prompt": "This is the sweep's source range from the user's main selection of the end cap.",
"range": {
"start": {
"line": 90,
"column": 5
},
"end": {
"line": 90,
"column": 35
}
},
"file": "ball-bearing.kcl"
}
],
"project_name": "test-project",
"files": {
"ball-bearing.kcl": "// Pillow Block Bearing\n// The ball bearing for the pillow block bearing assembly\n\n// Set units\n@settings(defaultLengthUnit = in)\n\n// Import Parameters\nimport * from \"parameters.kcl\"\n\n// Create the sketch of one of the balls. The ball diameter is sized as a fraction of the difference between inner and outer radius of the bearing\nballsSketch = startSketchOn(offsetPlane(XY, offset = stockThickness / 2))\n |> startProfile(at = [bearingBoreDiameter / 2 + 0.1, 0.001])\n |> arc(angleEnd = 0, angleStart = 180, radius = sphereDia / 2)\n |> close()\n\n// Revolve the ball to make a sphere and pattern around the inside wall\nballs = revolve(ballsSketch, axis = X)\n |> patternCircular3d(\n arcDegrees = 360,\n axis = [0, 0, 1],\n center = [0, 0, 0],\n instances = 16,\n rotateDuplicates = true,\n )\n\n// Create the sketch for the chain around the balls\nchainSketch = startSketchOn(offsetPlane(XY, offset = stockThickness / 2))\n |> startProfile(at = [\n bearingBoreDiameter / 2 + 0.1 + sphereDia / 2 - (chainWidth / 2),\n 0.125 * sin(60deg)\n ])\n |> arc(angleEnd = 60, angleStart = 120, radius = sphereDia / 2)\n |> line(end = [0, chainThickness])\n |> line(end = [-chainWidth, 0])\n |> close()\n\n// Revolve the chain sketch\nchainHead = revolve(chainSketch, axis = X)\n |> patternCircular3d(\n arcDegrees = 360,\n axis = [0, 0, 1],\n center = [0, 0, 0],\n instances = 16,\n rotateDuplicates = true,\n )\n\n// Create the sketch for the links in between the chains\nlinkSketch = startSketchOn(XZ)\n |> circle(\n center = [\n bearingBoreDiameter / 2 + 0.1 + sphereDia / 2,\n stockThickness / 2\n ],\n radius = linkDiameter / 2,\n )\n\n// Create the walls of the bearing\nbearingBody = startSketchOn(XZ)\nbearingUpper = startProfile(\n bearingBody,\n at = [\n bearingOuterDiameter / 2 - .07,\n stockThickness\n ],\n )\n |> angledLine(angle = -91, length = 0.05)\n |> xLine(length = -(bearingOuterDiameter / 2 - (bearingBoreDiameter / 2)) + .145)\n |> yLine(endAbsolute = 0.105)\n |> xLine(length = -0.025)\n |> angledLine(angle = 91, endAbsoluteY = profileStartY(%))\n |> line(endAbsolute = [profileStartX(%), profileStartY(%)])\n |> close()\n |> revolve(angle = 360, axis = Y)\n |> appearance(%, color = \"#121212\")\n\nbearingLower = startProfile(bearingBody, at = [bearingBoreDiameter / 2, 0.025])\n |> xLine(length = 0.05)\n |> angledLine(angle = 75, length = 0.04, tag = $seg01)\n |> xLine(length = 0.05)\n |> angledLine(angle = -75, length = segLen(seg01))\n |> xLine(endAbsolute = bearingOuterDiameter / 2)\n |> yLine(length = stockThickness)\n |> xLine(length = -0.07)\n |> angledLine(angle = -91, endAbsoluteY = profileStartY(%) + .075)\n |> xLine(endAbsolute = profileStartX(%) + .05)\n |> angledLine(angle = 91, endAbsoluteY = stockThickness * 1.25)\n |> xLine(endAbsolute = profileStartX(%))\n |> line(endAbsolute = [profileStartX(%), profileStartY(%)])\n |> close()\n |> revolve(angle = 360, axis = Y)\n |> appearance(%, color = \"#f0f0f0\")\n\n// Revolve the link sketch\nrevolve(linkSketch, axis = Y, angle = 360 / 16)\n |> patternCircular3d(\n arcDegrees = 360,\n axis = [0, 0, 1],\n center = [0, 0, 0],\n instances = 16,\n rotateDuplicates = true,\n )\n",
"block.kcl": "// Pillow Block Bearing\n// The machined block for the pillow block bearing assembly. The block is dimensioned using the bolt pattern spacing, and each bolt hole includes a counterbore\n\n// Set units\n@settings(defaultLengthUnit = in)\n\n// Import Parameters\nimport * from \"parameters.kcl\"\n\n// Calculate the dimensions of the block using the specified bolt spacing. The size of the block can be defined by adding a multiple of the counterbore diameter to the bolt spacing\nblockLength = boltSpacingX + counterboreDiameter + boltDiameter\nblockWidth = boltSpacingY + counterboreDiameter + boltDiameter\n\n// Draw the base plate\nplateSketch = startSketchOn(XY)\n |> startProfile(at = [-blockLength / 2, -blockWidth / 2])\n |> angledLine(angle = 0, length = blockLength, tag = $rectangleSegmentA001)\n |> angledLine(angle = segAng(rectangleSegmentA001) + 90, length = blockWidth, tag = $rectangleSegmentB001)\n |> angledLine(angle = segAng(rectangleSegmentA001), length = -segLen(rectangleSegmentA001), tag = $rectangleSegmentC001)\n |> line(endAbsolute = [profileStartX(%), profileStartY(%)], tag = $rectangleSegmentD001)\n |> close()\n |> subtract2d(tool = circle(center = [0, 0], radius = bearingOuterDiameter / 2))\nplateBody = extrude(plateSketch, length = stockThickness)\n |> appearance(%, color = \"#1e62eb\")\n |> fillet(\n radius = boltDiameter * 1 / 3,\n tags = [\n getNextAdjacentEdge(rectangleSegmentB001),\n getNextAdjacentEdge(rectangleSegmentA001),\n getNextAdjacentEdge(rectangleSegmentC001),\n getNextAdjacentEdge(rectangleSegmentD001)\n ],\n )\n\n// Define hole positions\nholePositions = [\n [-boltSpacingX / 2, -boltSpacingY / 2],\n [-boltSpacingX / 2, boltSpacingY / 2],\n [boltSpacingX / 2, -boltSpacingY / 2],\n [boltSpacingX / 2, boltSpacingY / 2]\n]\n\n// Function to create a counterbored hole\nfn counterbore(@holePosition) {\n cbBore = startSketchOn(plateBody, face = END)\n |> circle(center = holePosition, radius = counterboreDiameter / 2)\n |> extrude(length = -counterboreDepth)\n cbBolt = startSketchOn(cbBore, face = START)\n |> circle(center = holePosition, radius = boltDiameter / 2, tag = $hole01)\n |> extrude(length = -stockThickness + counterboreDepth)\n\n return { }\n}\n\n// Place a counterbored hole at each bolt hole position\nmap(holePositions, f = counterbore)\n",
"main.kcl": "// Pillow Block Bearing\n// A bearing pillow block, also known as a plummer block or pillow block bearing, is a pedestal used to provide support for a rotating shaft with the help of compatible bearings and various accessories. Housing a bearing, the pillow block provides a secure and stable foundation that allows the shaft to rotate smoothly within its machinery setup. These components are essential in a wide range of mechanical systems and machinery, playing a key role in reducing friction and supporting radial and axial loads.\n\n// Set units\n@settings(defaultLengthUnit = in)\n\n// Import parts and parameters\nimport * from \"parameters.kcl\"\nimport \"ball-bearing.kcl\" as ballBearing\nimport \"block.kcl\" as block\n\n// Render each part\nballBearing\nblock\n",
"parameters.kcl": "// Global parameters for the pillow block bearing\n\n// Set units\n@settings(defaultLengthUnit = in)\n\n// Export parameters\nexport boltSpacingX = 5\nexport boltSpacingY = 3\nexport boltDiameter = 3 / 8\nexport counterboreDiameter = 3 / 4\nexport counterboreDepth = 3 / 16\nexport stockThickness = .5\nexport bearingBoreDiameter = 1 + 3 / 4\nexport bearingOuterDiameter = bearingBoreDiameter * 1.5\nexport sphereDia = (bearingOuterDiameter - bearingBoreDiameter) / 4\nexport chainWidth = sphereDia / 2\nexport chainThickness = sphereDia / 8\nexport linkDiameter = sphereDia / 4\n"
},
"expectedFiles": {
"ball-bearing.kcl": "// Pillow Block Bearing\n// The ball bearing for the pillow block bearing assembly\n\n// Set units\n@settings(defaultLengthUnit = in)\n\n// Import Parameters\nimport * from \"parameters.kcl\"\n\n// Create the sketch of one of the balls. The ball diameter is sized as a fraction of the difference between inner and outer radius of the bearing\nballsSketch = startSketchOn(offsetPlane(XY, offset = stockThickness / 2))\n |> startProfile(at = [bearingBoreDiameter / 2 + 0.1, 0.001])\n |> arc(angleEnd = 0, angleStart = 180, radius = sphereDia / 2)\n |> close()\n\n// Revolve the ball to make a sphere and pattern around the inside wall\nballs = revolve(ballsSketch, axis = X)\n |> patternCircular3d(\n arcDegrees = 360,\n axis = [0, 0, 1],\n center = [0, 0, 0],\n instances = 16,\n rotateDuplicates = true,\n )\n\n// Create the sketch for the chain around the balls\nchainSketch = startSketchOn(offsetPlane(XY, offset = stockThickness / 2))\n |> startProfile(at = [\n bearingBoreDiameter / 2 + 0.1 + sphereDia / 2 - (chainWidth / 2),\n 0.125 * sin(60deg)\n ])\n |> arc(angleEnd = 60, angleStart = 120, radius = sphereDia / 2)\n |> line(end = [0, chainThickness])\n |> line(end = [-chainWidth, 0])\n |> close()\n\n// Revolve the chain sketch\nchainHead = revolve(chainSketch, axis = X)\n |> patternCircular3d(\n arcDegrees = 360,\n axis = [0, 0, 1],\n center = [0, 0, 0],\n instances = 16,\n rotateDuplicates = true,\n )\n\n// Create the sketch for the links in between the chains\nlinkSketch = startSketchOn(XZ)\n |> circle(\n center = [\n bearingBoreDiameter / 2 + 0.1 + sphereDia / 2,\n stockThickness / 2\n ],\n radius = linkDiameter / 2,\n )\n\n// Create the walls of the bearing\nbearingBody = startSketchOn(XZ)\nbearingUpper = startProfile(\n bearingBody,\n at = [\n bearingOuterDiameter / 2 - .07,\n stockThickness\n ],\n )\n |> angledLine(angle = -91, length = 0.05)\n |> xLine(length = -(bearingOuterDiameter / 2 - (bearingBoreDiameter / 2)) + .145)\n |> yLine(endAbsolute = 0.105)\n |> xLine(length = -0.025)\n |> angledLine(angle = 91, endAbsoluteY = profileStartY(%))\n |> line(endAbsolute = [profileStartX(%), profileStartY(%)])\n |> close()\n |> revolve(angle = 360, axis = Y)\n |> appearance(%, color = \"#121212\")\n\nbearingLower = startProfile(bearingBody, at = [bearingBoreDiameter / 2, 0.025])\n |> xLine(length = 0.05)\n |> angledLine(angle = 75, length = 0.04, tag = $seg01)\n |> xLine(length = 0.05)\n |> angledLine(angle = -75, length = segLen(seg01))\n |> xLine(endAbsolute = bearingOuterDiameter / 2)\n |> yLine(length = stockThickness)\n |> xLine(length = -0.07)\n |> angledLine(angle = -91, endAbsoluteY = profileStartY(%) + .075)\n |> xLine(endAbsolute = profileStartX(%) + .05)\n |> angledLine(angle = 91, endAbsoluteY = stockThickness * 1.25)\n |> xLine(endAbsolute = profileStartX(%))\n |> line(endAbsolute = [profileStartX(%), profileStartY(%)])\n |> close()\n |> revolve(angle = 360, axis = Y)\n |> appearance(%, color = \"#ff0000\")\n\n// Revolve the link sketch\nrevolve(linkSketch, axis = Y, angle = 360 / 16)\n |> patternCircular3d(\n arcDegrees = 360,\n axis = [0, 0, 1],\n center = [0, 0, 0],\n instances = 16,\n rotateDuplicates = true,\n )\n",
"block.kcl": "// Pillow Block Bearing\n// The machined block for the pillow block bearing assembly. The block is dimensioned using the bolt pattern spacing, and each bolt hole includes a counterbore\n\n// Set units\n@settings(defaultLengthUnit = in)\n\n// Import Parameters\nimport * from \"parameters.kcl\"\n\n// Calculate the dimensions of the block using the specified bolt spacing. The size of the block can be defined by adding a multiple of the counterbore diameter to the bolt spacing\nblockLength = boltSpacingX + counterboreDiameter + boltDiameter\nblockWidth = boltSpacingY + counterboreDiameter + boltDiameter\n\n// Draw the base plate\nplateSketch = startSketchOn(XY)\n |> startProfile(at = [-blockLength / 2, -blockWidth / 2])\n |> angledLine(angle = 0, length = blockLength, tag = $rectangleSegmentA001)\n |> angledLine(angle = segAng(rectangleSegmentA001) + 90, length = blockWidth, tag = $rectangleSegmentB001)\n |> angledLine(angle = segAng(rectangleSegmentA001), length = -segLen(rectangleSegmentA001), tag = $rectangleSegmentC001)\n |> line(endAbsolute = [profileStartX(%), profileStartY(%)], tag = $rectangleSegmentD001)\n |> close()\n |> subtract2d(tool = circle(center = [0, 0], radius = bearingOuterDiameter / 2))\nplateBody = extrude(plateSketch, length = stockThickness)\n |> appearance(%, color = \"#1e62eb\")\n |> fillet(\n radius = boltDiameter * 1 / 3,\n tags = [\n getNextAdjacentEdge(rectangleSegmentB001),\n getNextAdjacentEdge(rectangleSegmentA001),\n getNextAdjacentEdge(rectangleSegmentC001),\n getNextAdjacentEdge(rectangleSegmentD001)\n ],\n )\n\n// Define hole positions\nholePositions = [\n [-boltSpacingX / 2, -boltSpacingY / 2],\n [-boltSpacingX / 2, boltSpacingY / 2],\n [boltSpacingX / 2, -boltSpacingY / 2],\n [boltSpacingX / 2, boltSpacingY / 2]\n]\n\n// Function to create a counterbored hole\nfn counterbore(@holePosition) {\n cbBore = startSketchOn(plateBody, face = END)\n |> circle(center = holePosition, radius = counterboreDiameter / 2)\n |> extrude(length = -counterboreDepth)\n cbBolt = startSketchOn(cbBore, face = START)\n |> circle(center = holePosition, radius = boltDiameter / 2, tag = $hole01)\n |> extrude(length = -stockThickness + counterboreDepth)\n\n return { }\n}\n\n// Place a counterbored hole at each bolt hole position\nmap(holePositions, f = counterbore)\n",
"main.kcl": "// Pillow Block Bearing\n// A bearing pillow block, also known as a plummer block or pillow block bearing, is a pedestal used to provide support for a rotating shaft with the help of compatible bearings and various accessories. Housing a bearing, the pillow block provides a secure and stable foundation that allows the shaft to rotate smoothly within its machinery setup. These components are essential in a wide range of mechanical systems and machinery, playing a key role in reducing friction and supporting radial and axial loads.\n\n// Set units\n@settings(defaultLengthUnit = in)\n\n// Import parts and parameters\nimport * from \"parameters.kcl\"\nimport \"ball-bearing.kcl\" as ballBearing\nimport \"block.kcl\" as block\n\n// Render each part\nballBearing\nblock\n",
"parameters.kcl": "// Global parameters for the pillow block bearing\n\n// Set units\n@settings(defaultLengthUnit = in)\n\n// Export parameters\nexport boltSpacingX = 5\nexport boltSpacingY = 3\nexport boltDiameter = 3 / 8\nexport counterboreDiameter = 3 / 4\nexport counterboreDepth = 3 / 16\nexport stockThickness = .5\nexport bearingBoreDiameter = 1 + 3 / 4\nexport bearingOuterDiameter = bearingBoreDiameter * 1.5\nexport sphereDia = (bearingOuterDiameter - bearingBoreDiameter) / 4\nexport chainWidth = sphereDia / 2\nexport chainThickness = sphereDia / 8\nexport linkDiameter = sphereDia / 4\n"
}
},
"pattern this cylinder 6 times around the center of the flange, before subtracting it from the flange": {
"prompt": "pattern this cylinder 6 times around the center of the flange, before subtracting it from the flange",
"source_ranges": [
{
"prompt": "The users main selection is the wall of a general-sweep (that is an extrusion, revolve, sweep or loft).\nThe source range though is for the original segment before it was extruded, you can add a tag to that segment in order to refer to this wall, for example \"startSketchOn(someSweepVariable, face = segmentTag)\"\nBut it's also worth bearing in mind that the user may have intended to select the sweep itself, not this individual wall, see later source ranges for more context. about the sweep",
"range": {
"start": {
"line": 11,
"column": 22
},
"end": {
"line": 11,
"column": 89
}
},
"file": "main.kcl"
},
{
"prompt": "This is the sweep's source range from the user's main selection of the end cap.",
"range": {
"start": {
"line": 12,
"column": 24
},
"end": {
"line": 12,
"column": 66
}
},
"file": "main.kcl"
}
],
"project_name": "test-project",
"files": {
"main.kcl": "flangeHolesR = 6\nflangeBodySketch = startSketchOn(XY)\nflangeBodyProfile = circle(flangeBodySketch, center = [0, 0], radius = 100)\nflangePlate = extrude(flangeBodyProfile, length = 5)\nhigherPlane = offsetPlane(XY, offset = 10)\ninnerBoreSketch = startSketchOn(higherPlane)\ninnerBoreProfile = circle(innerBoreSketch, center = [0, 0], radius = 49.28)\ninnerBoreCylinder = extrude(innerBoreProfile, length = -10)\nflangeBody = subtract([flangePlate], tools = [innerBoreCylinder])\nmountingHoleSketch = startSketchOn(higherPlane)\nmountingHoleProfile = circle(mountingHoleSketch, center = [75, 0], radius = flangeHolesR)\nmountingHoleCylinders = extrude(mountingHoleProfile, length = -30)\n"
},
"expectedFiles": {
"main.kcl": "flangeHolesR = 6\nflangeBodySketch = startSketchOn(XY)\nflangeBodyProfile = circle(flangeBodySketch, center = [0, 0], radius = 100)\nflangePlate = extrude(flangeBodyProfile, length = 5)\nhigherPlane = offsetPlane(XY, offset = 10)\ninnerBoreSketch = startSketchOn(higherPlane)\ninnerBoreProfile = circle(innerBoreSketch, center = [0, 0], radius = 49.28)\ninnerBoreCylinder = extrude(innerBoreProfile, length = -10)\nflangeBody = subtract([flangePlate], tools = [innerBoreCylinder])\nmountingHoleSketch = startSketchOn(higherPlane)\nmountingHoleProfile = circle(mountingHoleSketch, center = [75, 0], radius = flangeHolesR)\nmountingHoleCylinders = extrude(mountingHoleProfile, length = -30)\n |> patternCircular3d(instances = 6, axis = Z, center = [0, 0, 0])\nflange = subtract([flangeBody], tools = [mountingHoleCylinders])\n"
}
},
"fillet these two edges please": {
"prompt": "fillet these two edges please",
"source_ranges": [
{
"prompt": "The users main selection is the edge of a general-sweep (that is an extrusion, revolve, sweep or loft).\nit is an opposite edge, in order to refer to this edge you should add a tag to the segment function in this source range,\nand then use the function getOppositeEdge\nSee later source ranges for more context. about the sweep",
"range": {
"start": {
"line": 3,
"column": 5
},
"end": {
"line": 3,
"column": 26
}
},
"file": "main.kcl"
},
{
"prompt": "This is the sweep's source range from the user's main selection of the end cap.",
"range": {
"start": {
"line": 11,
"column": 13
},
"end": {
"line": 11,
"column": 45
}
},
"file": "main.kcl"
},
{
"prompt": "The users main selection is the edge of a general-sweep (that is an extrusion, revolve, sweep or loft).\nit is an opposite edge, in order to refer to this edge you should add a tag to the segment function in this source range,\nand then use the function getOppositeEdge\nSee later source ranges for more context. about the sweep",
"range": {
"start": {
"line": 7,
"column": 5
},
"end": {
"line": 7,
"column": 27
}
},
"file": "main.kcl"
},
{
"prompt": "This is the sweep's source range from the user's main selection of the end cap.",
"range": {
"start": {
"line": 11,
"column": 13
},
"end": {
"line": 11,
"column": 45
}
},
"file": "main.kcl"
}
],
"project_name": "test-project",
"files": {
"main.kcl": "sketch001 = startSketchOn(XZ)\nprofile001 = startProfile(sketch001, at = [18.47, 15.31])\n |> yLine(length = 28.26)\n |> line(end = [55.52, 21.93])\n |> tangentialArc(endAbsolute = [136.09, 36.87])\n |> yLine(length = -45.48)\n |> xLine(length = -13.76)\n |> yLine(length = 8.61)\n |> line(endAbsolute = [profileStartX(%), profileStartY(%)])\n |> close()\nextrude001 = extrude(profile001, length = 10)\n"
},
"expectedFiles": {
"main.kcl": "sketch001 = startSketchOn(XZ)\nprofile001 = startProfile(sketch001, at = [18.47, 15.31])\n |> yLine(length = 28.26, tag = $seg02)\n |> line(end = [55.52, 21.93], tag = $seg01)\n |> tangentialArc(endAbsolute = [136.09, 36.87])\n |> yLine(length = -45.48)\n |> xLine(length = -13.76, tag = $seg03)\n |> yLine(length = 8.61)\n |> line(endAbsolute = [profileStartX(%), profileStartY(%)])\n |> close()\nextrude001 = extrude(profile001, length = 10, tagEnd = $capEnd001)\n |> fillet(\n radius = 1,\n tags = [\n getCommonEdge(faces = [seg01, seg02]),\n getCommonEdge(faces = [seg03, capEnd001])\n ],\n )\n"
}
}
}

View File

@ -0,0 +1,859 @@
import { engineCommandManager, kclManager } from '@src/lib/singletons'
import { VITE_KC_DEV_TOKEN } from '@src/env'
import { getModuleIdByFileName, isArray } from '@src/lib/utils'
import { vi, inject } from 'vitest'
import { assertParse } from '@src/lang/wasm'
import { initPromise } from '@src/lang/wasmUtils'
import { getCodeRefsByArtifactId } from '@src/lang/std/artifactGraph'
import path from 'path'
import fs from 'node:fs'
import type { Selections } from '@src/lib/selections'
/**
* This test is some what unique, and in fact it doesn't assert anything aside from capturing snapshots
* The snap shots are the request to the ML iteration endpoint, so that the ML team can use these in their own
* test harness.
* The reason why this is done at all is because the frontEnd has to be the source of truth because in the case of a user
* selecting something in the UI, the UI turns that artifactGraph selection into meta prompt that accompanies the user's prompt
*
* These are down as unit tests, because when a user selects something in the UI, that click resolves to an artifact in the artifactGraph
* So long as we're able to find the same artifact in the graph, we don't need the click.
* So far `artifactSearchSnippet` that has the file name, a searchString (some code in that file) and the artifact type ('wall', sweepEdge' etc) has
* been enough to find the artifact in the graph. That might need to change with more examples OR later if we have rock-solid stable ids
* We can possible just hard-code the ids and have that be reliable.
*
* The snapshot code is custom, instead of using Vitest's built-in snapshot functionality.
* This is purely because we want pure JSON to make this easy for the ML team to ingest
* It's been made to still work with the same `-u` flag, so it won't feel meaningfully different
* When they need to be updated.
*
* The way to add more examples is pushing new cases to `cases` array, you should be able
* to follow the patterns of other examples.
*/
// Custom JSON snapshot utilities
const SNAPSHOTS_DIR = path.join(__dirname, '__snapshots__')
const SNAPSHOTS_FILE = path.join(SNAPSHOTS_DIR, 'modifyWithTTC.test.json')
interface JsonSnapshots {
[testName: string]: any
}
function loadJsonSnapshots(): JsonSnapshots {
try {
if (fs.existsSync(SNAPSHOTS_FILE)) {
const content = fs.readFileSync(SNAPSHOTS_FILE, 'utf-8')
return JSON.parse(content)
}
} catch (error) {
console.warn('Failed to load JSON snapshots:', error)
}
return {}
}
function saveJsonSnapshots(snapshots: JsonSnapshots): void {
try {
// Create directory if it doesn't exist
if (!fs.existsSync(SNAPSHOTS_DIR)) {
fs.mkdirSync(SNAPSHOTS_DIR, { recursive: true })
}
fs.writeFileSync(
SNAPSHOTS_FILE,
JSON.stringify(snapshots, null, 2),
'utf-8'
)
} catch (error) {
console.error('Failed to save JSON snapshots:', error)
throw error
}
}
function expectJsonSnapshot(testName: string, data: any): void {
const snapshots = loadJsonSnapshots()
const serializedData = JSON.parse(JSON.stringify(data)) // Deep clone to remove any functions/symbols
// Try to detect update mode using inject
let isUpdateMode = false
try {
isUpdateMode = inject('vitest:updateSnapshots') || false
} catch {
// If inject fails, fall back to environment variable approach
isUpdateMode =
process.env.VITEST_UPDATE_SNAPSHOTS === 'true' ||
process.env.UPDATE_SNAPSHOTS === 'true'
}
if (isUpdateMode) {
// Update mode: save the new snapshot
snapshots[testName] = serializedData
saveJsonSnapshots(snapshots)
} else {
// Compare mode: check against existing snapshot
if (!(testName in snapshots)) {
throw new Error(`Snapshot missing for "${testName}". To update snapshots, run:
npm run test:unit -- -u modifyWithTTC.test.ts
Or set the UPDATE_SNAPSHOTS environment variable:
UPDATE_SNAPSHOTS=true npm run test:unit -- modifyWithTTC.test.ts`)
}
const expected = snapshots[testName]
try {
expect(serializedData).toEqual(expected)
} catch (error) {
throw new Error(`Snapshot mismatch for "${testName}". To update snapshots, run:
npm run test:unit -- -u modifyWithTTC.test.ts
Or set the UPDATE_SNAPSHOTS environment variable:
UPDATE_SNAPSHOTS=true npm run test:unit -- modifyWithTTC.test.ts
Original error: ${error instanceof Error ? error.message : String(error)}`)
}
}
}
export function loadSampleProject(fileName: string): {
[fileName: string]: string
} {
// public/kcl-samples/pillow-block-bearing/main.kcl
const projectPath = path.join('public', 'kcl-samples', fileName)
// load in all .kcl files in this directory using fs (sync)
const files: { [fileName: string]: string } = {}
const dir = path.dirname(projectPath)
const fileNames = fs.readdirSync(dir)
for (const file of fileNames) {
if (file.endsWith('.kcl')) {
const content = fs.readFileSync(path.join(dir, file), 'utf-8')
files[file] = content
}
}
return files
}
type TestCase = {
testName: string
prompt: string
inputFiles: { [fileName: string]: string }
expectedFiles: { [fileName: string]: string }
artifactSearchSnippet?: { fileName: string; content: string; type: string }[]
}
function createCaseData({
prompt,
inputFiles,
artifactSearchSnippet,
expectFilesCallBack,
testName,
}: Omit<TestCase, 'expectedFiles'> & {
expectFilesCallBack: (input: { fileName: string; content: string }) => string
}): TestCase {
return {
testName,
prompt,
inputFiles,
artifactSearchSnippet,
expectedFiles: Object.fromEntries(
Object.entries(inputFiles).map(([fileName, content]) => [
fileName,
expectFilesCallBack({ fileName, content }),
])
),
}
}
const cases: TestCase[] = [
// // Add the static test case
createCaseData({
testName: 'change color',
prompt: 'make this neon green please, use #39FF14',
artifactSearchSnippet: [
{
content: 'line(end = [19.66, -116.4])',
fileName: 'main.kcl',
type: 'wall',
},
],
expectFilesCallBack: ({ fileName, content }) => {
if (fileName !== 'main.kcl') return content
return content.replace(
'extrude001 = extrude(profile001, length = 200)',
`extrude001 = extrude(profile001, length = 200)
|> appearance(color = "#39FF14")`
)
},
inputFiles: {
'main.kcl': `import "b.kcl" as b
sketch001 = startSketchOn(XZ)
profile001 = startProfile(sketch001, at = [57.81, 250.51])
|> line(end = [121.13, 56.63], tag = $seg02)
|> line(end = [83.37, -34.61], tag = $seg01)
|> line(end = [19.66, -116.4])
|> line(end = [-221.8, -41.69])
|> line(endAbsolute = [profileStartX(%), profileStartY(%)])
|> close()
extrude001 = extrude(profile001, length = 200)
sketch002 = startSketchOn(XZ)
|> startProfile(at = [-73.64, -42.89])
|> xLine(length = 173.71)
|> line(end = [-22.12, -94.4])
|> line(end = [-22.12, -50.4])
|> line(end = [-22.12, -94.4])
|> line(end = [-22.12, -50.4])
|> xLine(length = -156.98)
|> line(endAbsolute = [profileStartX(%), profileStartY(%)])
|> close()
extrude002 = extrude(sketch002, length = 50)
b
`,
'b.kcl': `sketch003 = startSketchOn(XY)
|> startProfile(at = [52.92, 157.81])
|> angledLine(angle = 0, length = 176.4, tag = $rectangleSegmentA001)
|> angledLine(
angle = segAng(rectangleSegmentA001) - 90,
length = 53.4,
tag = $rectangleSegmentB001,
)
|> angledLine(angle = segAng(rectangleSegmentA001), length = -segLen(rectangleSegmentA001), tag = $rectangleSegmentC001)
|> line(end = [-22.12, -50.4])
|> line(end = [-22.12, -94.4])
|> line(endAbsolute = [profileStartX(%), profileStartY(%)])
|> close()
extrude(sketch003, length = 20)
`,
},
}),
// Load pillow block files and add as another test case
createCaseData({
testName: 'change color on imported file',
artifactSearchSnippet: [
{
fileName: 'ball-bearing.kcl',
content: 'yLine(length = stockThickness)',
type: 'wall',
},
],
prompt: 'Change this to red please, #ff0000',
inputFiles: loadSampleProject('pillow-block-bearing/main.kcl'),
expectFilesCallBack: ({ fileName, content }) =>
fileName === 'ball-bearing.kcl'
? content.replace(
'appearance(%, color = "#f0f0f0")',
'appearance(%, color = "#ff0000")'
)
: content,
}),
]
const patternHoleStarterCode: { [fileName: string]: string } = {
'main.kcl': `flangeHolesR = 6
flangeBodySketch = startSketchOn(XY)
flangeBodyProfile = circle(flangeBodySketch, center = [0, 0], radius = 100)
flangePlate = extrude(flangeBodyProfile, length = 5)
higherPlane = offsetPlane(XY, offset = 10)
innerBoreSketch = startSketchOn(higherPlane)
innerBoreProfile = circle(innerBoreSketch, center = [0, 0], radius = 49.28)
innerBoreCylinder = extrude(innerBoreProfile, length = -10)
flangeBody = subtract([flangePlate], tools = [innerBoreCylinder])
mountingHoleSketch = startSketchOn(higherPlane)
mountingHoleProfile = circle(mountingHoleSketch, center = [75, 0], radius = flangeHolesR)
mountingHoleCylinders = extrude(mountingHoleProfile, length = -30)
`,
}
cases.push(
createCaseData({
testName: 'pattern holes',
artifactSearchSnippet: [
{
fileName: 'main.kcl',
content:
'circle(mountingHoleSketch, center = [75, 0], radius = flangeHolesR)',
type: 'wall',
},
],
prompt:
'pattern this cylinder 6 times around the center of the flange, before subtracting it from the flange',
inputFiles: patternHoleStarterCode,
expectFilesCallBack: ({ fileName, content }) => {
if (fileName !== 'main.kcl') return content
return content.replace(
'extrude(mountingHoleProfile, length = -30)',
`extrude(mountingHoleProfile, length = -30)
|> patternCircular3d(instances = 6, axis = Z, center = [0, 0, 0])
flange = subtract([flangeBody], tools = [mountingHoleCylinders])`
)
},
})
)
const filletStarterCode: { [fileName: string]: string } = {
'main.kcl': `sketch001 = startSketchOn(XZ)
profile001 = startProfile(sketch001, at = [18.47, 15.31])
|> yLine(length = 28.26)
|> line(end = [55.52, 21.93])
|> tangentialArc(endAbsolute = [136.09, 36.87])
|> yLine(length = -45.48)
|> xLine(length = -13.76)
|> yLine(length = 8.61)
|> line(endAbsolute = [profileStartX(%), profileStartY(%)])
|> close()
extrude001 = extrude(profile001, length = 10)
`,
}
cases.push(
createCaseData({
testName: 'fillet shape',
artifactSearchSnippet: [
{
fileName: 'main.kcl',
content: 'yLine(length = 28.26)',
type: 'sweepEdge',
},
{
fileName: 'main.kcl',
content: 'xLine(length = -13.76)',
type: 'sweepEdge',
},
],
prompt: 'fillet these two edges please',
inputFiles: filletStarterCode,
expectFilesCallBack: ({ fileName, content }) => {
if (fileName !== 'main.kcl') return content
let newContent = content.replace(
'extrude(profile001, length = 10)',
`extrude(profile001, length = 10, tagEnd = $capEnd001)
|> fillet(
radius = 1,
tags = [
getCommonEdge(faces = [seg01, seg02]),
getCommonEdge(faces = [seg03, capEnd001])
],
)`
)
newContent = newContent.replace(
'yLine(length = 28.26)',
'yLine(length = 28.26, tag = $seg02)'
)
newContent = newContent.replace(
'line(end = [55.52, 21.93])',
'line(end = [55.52, 21.93], tag = $seg01)'
)
newContent = newContent.replace(
'xLine(length = -13.76)',
'xLine(length = -13.76, tag = $seg03)'
)
return newContent
},
})
)
// Store original method to restore in afterAll
beforeAll(async () => {
await initPromise
// THESE TEST WILL FAIL without VITE_KC_DEV_TOKEN set in .env.development.local
await new Promise((resolve) => {
engineCommandManager.start({
token: VITE_KC_DEV_TOKEN,
width: 256,
height: 256,
setMediaStream: () => {},
setIsStreamReady: () => {},
callbackOnEngineLiteConnect: () => {
resolve(true)
},
})
})
}, 30_000)
afterAll(() => {
// Restore the original method
engineCommandManager.tearDown()
})
// Define mock implementations that will be referenced in vi.mock calls
vi.mock('@src/components/SetHorVertDistanceModal', () => ({
createInfoModal: vi.fn(() => ({
open: vi.fn().mockResolvedValue({
value: '10',
segName: 'test',
valueNode: {},
newVariableInsertIndex: 0,
sign: 1,
}),
})),
GetInfoModal: vi.fn(),
}))
vi.mock('@src/components/SetAngleLengthModal', () => ({
createSetAngleLengthModal: vi.fn(() => ({
open: vi.fn().mockResolvedValue({
value: '45',
segName: 'test',
valueNode: {},
newVariableInsertIndex: 0,
sign: 1,
}),
})),
SetAngleLengthModal: vi.fn(),
}))
// Create a utility to spy on fetch requests, similar to the Playwright fixture
interface CapturedRequest {
url: string
method: string
headers: Record<string, string>
body: {
[key: string]: any
files?: Record<string, string>
}
timestamp: number
}
interface FetchSpyOptions {
captureAllRequests?: boolean
mockResponses?: Record<string, any>
}
function createFetchSpy(options: FetchSpyOptions = {}) {
const capturedRequests: CapturedRequest[] = []
const allFetchCalls: string[] = []
// Store test context for file mapping
let currentTestFiles: Record<string, string> = {}
// Create a mock fetch that handles the specific text-to-cad endpoints
const mockFetch = vi.fn(
async (url: string | URL | Request, init?: RequestInit) => {
const urlString = url.toString()
allFetchCalls.push(urlString)
// Capture requests based on options
const shouldCapture =
options.captureAllRequests ||
(urlString.includes('text-to-cad') && urlString.includes('iteration'))
if (shouldCapture) {
try {
const headers: Record<string, string> = {}
if (init?.headers) {
// Convert headers to a plain object
if (init.headers instanceof Headers) {
init.headers.forEach((value, key) => {
headers[key] = value
})
} else if (isArray(init.headers)) {
init.headers.forEach(([key, value]) => {
headers[key] = value
})
} else {
Object.assign(headers, init.headers)
}
}
let requestBody: any = {}
let files: Record<string, string> = {}
// Parse multipart form data if present
if (init?.body instanceof FormData) {
// Extract the JSON body
const bodyData = init.body.get('body')
if (bodyData) {
requestBody = JSON.parse(bodyData.toString())
}
// Extract files and map them to correct names using test context
const fileContents: string[] = []
for (const [key, value] of init.body.entries()) {
if (key === 'files' && value instanceof File) {
const text = await value.text()
fileContents.push(text)
}
}
// Map files to their correct names based on test context
const testFileNames = Object.keys(currentTestFiles)
fileContents.forEach((content, index) => {
// Find matching file by content
const matchingFileName = testFileNames.find(
(fileName) => currentTestFiles[fileName] === content
)
if (matchingFileName) {
files[matchingFileName] = content
} else {
// Fallback if no exact match found
files[testFileNames[index] || `file-${index + 1}.kcl`] = content
}
})
} else if (init?.body && typeof init.body === 'string') {
// Parse multipart data manually like Playwright does
const postData = init.body
// Extract boundary from Content-Type header or find it in the data
const boundary = postData.match(
/------WebKitFormBoundary[^\r\n]*/
)?.[0]
if (boundary) {
const parts = postData
.split(boundary)
.filter((part) => part.trim())
for (const part of parts) {
// Skip the final boundary marker
if (part.startsWith('--')) continue
const nameMatch = part.match(/name="([^"]+)"/)
if (!nameMatch) continue
const name = nameMatch[1]
const content = part.split(/\r?\n\r?\n/)[1]?.trim()
if (!content) continue
if (name === 'body') {
requestBody = JSON.parse(content)
} else {
// This should be a file with the original filename as the key
files[name] = content
}
}
}
} else if (init?.body) {
// Handle JSON body
try {
requestBody = JSON.parse(init.body.toString())
} catch {
requestBody = { raw: init.body.toString() }
}
}
capturedRequests.push({
url: urlString,
method: init?.method || 'GET',
headers,
body: {
...requestBody,
files,
},
timestamp: Date.now(),
})
} catch (error) {
console.error('Error capturing request:', error)
}
}
// Check for custom mock responses
if (options.mockResponses) {
for (const [pattern, response] of Object.entries(
options.mockResponses
)) {
if (urlString.includes(pattern)) {
return new Response(JSON.stringify(response), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
}
}
// Mock text-to-cad iteration endpoint
if (
urlString.includes('text-to-cad') &&
urlString.includes('iteration')
) {
return new Response(
JSON.stringify({
id: '550e8400-e29b-41d4-a716-446655440000',
status: 'queued',
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
)
}
// Mock the async operations status endpoint
if (urlString.includes('async/operations/')) {
return new Response(
JSON.stringify({
id: '550e8400-e29b-41d4-a716-446655440000',
status: 'completed',
outputs: {
'main.kcl': 'mocked KCL content with appearance applied',
},
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
)
}
// For any other requests, return a basic successful response to avoid network errors
return new Response(JSON.stringify({ mocked: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
})
}
) as typeof global.fetch
// Use vi.stubGlobal for comprehensive mocking
vi.stubGlobal('fetch', mockFetch)
return {
getCapturedRequests: () => capturedRequests,
getRequestsMatching: (predicate: (req: CapturedRequest) => boolean) =>
capturedRequests.filter(predicate),
getTextToCadRequests: () =>
capturedRequests.filter((req) => req.url.includes('text-to-cad')),
getAllFetchCalls: () => allFetchCalls,
clearCapturedRequests: () => capturedRequests.splice(0),
setTestFiles: (files: Record<string, string>) => {
currentTestFiles = files
},
restore: () => {
vi.unstubAllGlobals()
},
}
}
// Add this function before the test cases
// Utility function to wait for a condition to be met
const waitForCondition = async (
condition: () => boolean,
timeout = 5000,
interval = 100
) => {
const startTime = Date.now()
while (Date.now() - startTime < timeout) {
try {
if (condition()) {
return true
}
} catch {
// Ignore errors, keep polling
}
// Wait for the next interval
await new Promise((resolve) => setTimeout(resolve, interval))
}
// Last attempt before failing
return condition()
}
// Add this function before the test cases
// Utility function to set up a test project directory with KCL files
async function setupTestProjectWithImports(testFiles: Record<string, string>) {
const os = require('os')
const fs = require('fs/promises')
const path = require('path')
const testProjectDir = path.join(os.tmpdir(), `kcl-test-${Date.now()}`)
// Set up test files
await fs.mkdir(testProjectDir, { recursive: true })
// Write all the test files
for (const [filename, content] of Object.entries(testFiles)) {
await fs.writeFile(path.join(testProjectDir, filename), content)
}
// Configure the FileSystemManager to use our test directory
const { fileSystemManager } = await import('@src/lang/std/fileSystemManager')
fileSystemManager.dir = testProjectDir
return {
projectDir: testProjectDir,
cleanup: async () => {
try {
await fs.rm(testProjectDir, { recursive: true })
} catch {
// Ignore cleanup errors
}
},
}
}
describe('When prompting modify with TTC, prompt:', () => {
cases.forEach(
({ prompt, inputFiles, artifactSearchSnippet, expectedFiles }) => {
it(`${prompt}`, async () => {
const mainFile = inputFiles['main.kcl']
const { cleanup } = await setupTestProjectWithImports(inputFiles)
// Set up fetch spy to capture requests
const fetchSpy = createFetchSpy()
// Set the test files for proper filename mapping
fetchSpy.setTestFiles(inputFiles)
// Set up mock token for authentication
const mockToken = 'test-token-123'
localStorage.setItem('TOKEN_PERSIST_KEY', mockToken)
try {
// Parse and execute the main file with imports
const ast = assertParse(mainFile)
// Execute the AST - the fileSystemManager.dir will be used for import resolution
await kclManager.executeAst({ ast })
expect(kclManager.errors).toEqual([])
let selections: Selections = {
graphSelections: [],
otherSelections: [],
}
if (artifactSearchSnippet) {
artifactSearchSnippet.forEach((snippet) => {
let moduleId = getModuleIdByFileName(
snippet.fileName,
kclManager.execState.filenames
)
if (snippet.fileName === 'main.kcl') {
moduleId = 0
}
const moduleContent = inputFiles[snippet.fileName]
if (moduleId === -1) {
throw new Error(
`Module ID not found for file: ${snippet.fileName}`
)
}
if (!moduleContent) {
throw new Error(
`Module content not found for file: ${snippet.fileName}`
)
}
const indexOfInterest = moduleContent.indexOf(snippet.content)
const artifacts = [...kclManager.artifactGraph].filter(
([id, artifact]) => {
const codeRefs = getCodeRefsByArtifactId(
id,
kclManager.artifactGraph
)
return (
artifact?.type === snippet.type &&
codeRefs &&
codeRefs.find((ref) => {
return (
ref.range[0] <= indexOfInterest &&
ref.range[1] >= indexOfInterest &&
ref.range[2] === moduleId
)
})
)
}
)
const artifact = artifacts?.[0]?.[1]
if (!artifact) {
throw new Error('Artifact not found')
}
const codeRef = (getCodeRefsByArtifactId(
artifact.id,
kclManager.artifactGraph
) || [])[0]
if (!codeRef) {
throw new Error('Code reference not found for the artifact')
}
selections.graphSelections.push({
artifact,
codeRef,
})
})
}
// Test that we can work with the imported content
// Test direct call to promptToEditFlow instead of going through state machine
const { promptToEditFlow } = await import('@src/lib/promptToEdit')
// Create project files that match what the state machine would create
const projectFiles = Object.entries(inputFiles).map(
([filename, content]) => ({
type: 'kcl' as const,
relPath: filename,
absPath: filename,
fileContents: content,
execStateFileNamesIndex: Number(
Object.entries(kclManager.execState.filenames).find(
([_, value]) =>
value && value.type === 'Local' && value.value === filename
)?.[0] || 0
),
})
)
// Call promptToEditFlow directly
const resultPromise = promptToEditFlow({
prompt,
selections,
projectFiles,
token: mockToken,
artifactGraph: kclManager.artifactGraph,
projectName: 'test-project',
filePath: 'main.kcl',
})
// Wait for the request to be made
await waitForCondition(
() => {
const requests = fetchSpy.getCapturedRequests()
return requests.length > 0
},
10000,
500
)
// Get and verify the captured request
const capturedRequests = fetchSpy.getCapturedRequests()
fetchSpy.getAllFetchCalls()
if (capturedRequests.length === 0) {
console.log(
'No text-to-cad requests were captured. This might indicate an error in the flow.'
)
expect(capturedRequests).toHaveLength(1) // This will fail and show what was captured
} else {
const request = capturedRequests[0]
const { kcl_version, ...body } = request.body // peel off and discard kcl_version
const textToCadPayload = {
...body,
// Normalize file names to make snapshots deterministic
files: request.body.files,
expectedFiles,
}
// delete textToCadPayload.kcl_version
// Use custom JSON snapshot instead of Vitest's default format
expectJsonSnapshot(prompt, textToCadPayload)
}
// Wait for the promise to resolve or reject
try {
await resultPromise
} catch {
// most likely get a auth error here for TTC, we don't actually care about the response.
// just capturing the request
}
} finally {
fetchSpy.restore()
localStorage.removeItem('TOKEN_PERSIST_KEY')
await cleanup()
}
}, 20_000) // Increase timeout to 20 seconds
}
)
})

View File

@ -14,12 +14,53 @@ import { Themes, getSystemTheme } from '@src/lib/theme'
import { reportRejection } from '@src/lib/trap'
import { toSync } from '@src/lib/utils'
import { authActor, useSettings } from '@src/lib/singletons'
import { APP_VERSION, generateSignInUrl } from '@src/routes/utils'
import { APP_VERSION } from '@src/routes/utils'
const subtleBorder =
'border border-solid border-chalkboard-30 dark:border-chalkboard-80'
const cardArea = `${subtleBorder} rounded-lg px-6 py-3 text-chalkboard-70 dark:text-chalkboard-30`
// OAuth provider types - matching the API types
type AccountProvider = 'github' | 'google' | 'apple' | 'microsoft' | 'discord'
// OAuth client info type to match API response
interface OAuth2ClientInfo {
url?: string
}
async function handleOAuthSignin(
provider: AccountProvider,
callback_url: string
) {
try {
const endpoint =
VITE_KC_API_BASE_URL +
'/oauth2/provider/' +
provider +
'/consent?callback_url=' +
encodeURIComponent(callback_url)
// This will get our auth URL and state.
const resp = await fetch(endpoint, {
method: 'GET',
})
if (!resp.ok) {
toast.error('Login is unavailable.')
return
}
const info: OAuth2ClientInfo = await resp.json()
// If there is a url, redirect to it.
if (info && info.url && info.url.length > 0) {
window.location.href = info.url
}
} catch {
toast.error('Login is unavailable.')
}
}
const SignIn = () => {
// Only create the native file menus on desktop
if (isDesktop()) {
@ -35,9 +76,14 @@ const SignIn = () => {
const {
app: { theme },
} = useSettings()
const signInUrl = generateSignInUrl()
const kclSampleUrl = `${VITE_KC_SITE_BASE_URL}/docs/kcl-samples/car-wheel-assembly`
// OAuth callback URL for webapp
const webappCallbackUrl =
typeof window !== 'undefined'
? window.location.href.replace('signin', '')
: ''
const getThemeText = useCallback(
(shouldContrast = true) =>
theme.current === Themes.Light ||
@ -55,7 +101,7 @@ const SignIn = () => {
// We want to invoke our command to login via device auth.
const userCodeToDisplay = await window.electron
.startDeviceFlow(VITE_KC_API_BASE_URL + location.search)
.catch(reportError)
.catch(reportRejection)
if (!userCodeToDisplay) {
console.error('No user code received while trying to log in')
toast.error('Error while trying to log in')
@ -64,7 +110,9 @@ const SignIn = () => {
setUserCode(userCodeToDisplay)
// Now that we have the user code, we can kick off the final login step.
const token = await window.electron.loginWithDeviceFlow().catch(reportError)
const token = await window.electron
.loginWithDeviceFlow()
.catch(reportRejection)
if (!token) {
console.error('No token received while trying to log in')
toast.error('Error while trying to log in')
@ -78,6 +126,16 @@ const SignIn = () => {
setUserCode('')
}
const handleOAuthClick = (provider: AccountProvider) => {
return (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
event.preventDefault()
handleOAuthSignin(provider, webappCallbackUrl).catch(reportRejection)
}
}
// OAuth button styling
const oauthButtonClasses = `w-full flex items-center justify-center gap-3 px-4 py-3 rounded-lg border ${subtleBorder} bg-chalkboard-10 dark:bg-chalkboard-90 hover:bg-chalkboard-20 dark:hover:bg-chalkboard-80 text-chalkboard-90 dark:text-chalkboard-10 transition-colors`
return (
<main
className="bg-primary h-screen grid place-items-stretch m-0 p-2"
@ -167,24 +225,63 @@ const SignIn = () => {
) : (
<>
<div className="flex md:hidden flex-col gap-2">
<p className="text-base text-primary">
<p className="text-base text-primary mb-4">
This app is really best used on a desktop. We're working on
simple touch controls for mobile, but in the meantime please
visit using a larger device.
</p>
</div>
<Link
onClick={openExternalBrowserIfDesktop(signInUrl)}
to={signInUrl}
className={
'w-fit m-0 mt-8 hidden md:flex gap-4 items-center px-3 py-1 ' +
'!border-transparent !text-lg !text-chalkboard-10 !bg-primary hover:hue-rotate-15'
}
data-testid="sign-in-button"
>
Sign in to get started
<CustomIcon name="arrowRight" className="w-6 h-6" />
</Link>
<div className="hidden md:block mt-8">
<h2 className="text-xl mb-4 text-chalkboard-90 dark:text-chalkboard-10">
Sign in to get started
</h2>
<p className="text-sm mb-6 text-chalkboard-70 dark:text-chalkboard-30">
No password setup necessary. When you sign into Zoo for the
first time we create your account automatically.
</p>
<div className="flex flex-col gap-3 max-w-sm">
<button
onClick={handleOAuthClick('github')}
className={oauthButtonClasses}
data-testid="github-signin-button"
>
<CustomIcon name="code" className="w-5 h-5" />
<span>Continue with GitHub</span>
</button>
<button
onClick={handleOAuthClick('google')}
className={oauthButtonClasses}
data-testid="google-signin-button"
>
<CustomIcon name="search" className="w-5 h-5" />
<span>Continue with Google</span>
</button>
<button
onClick={handleOAuthClick('apple')}
className={oauthButtonClasses}
data-testid="apple-signin-button"
>
<CustomIcon name="star" className="w-5 h-5" />
<span>Continue with Apple</span>
</button>
<button
onClick={handleOAuthClick('microsoft')}
className={oauthButtonClasses}
data-testid="microsoft-signin-button"
>
<CustomIcon name="settings" className="w-5 h-5" />
<span>Continue with Microsoft</span>
</button>
<button
onClick={handleOAuthClick('discord')}
className={oauthButtonClasses}
data-testid="discord-signin-button"
>
<CustomIcon name="chat" className="w-5 h-5" />
<span>Continue with Discord</span>
</button>
</div>
</div>
</>
)}
</div>

View File

@ -0,0 +1,17 @@
import type { GlobalSetupContext } from 'vitest/node'
export default function setup({ provide, config }: GlobalSetupContext) {
// Check if snapshot update mode is enabled
// Only update existing snapshots when mode is 'all' (triggered by -u flag)
// 'new' mode only creates snapshots for tests that don't have any, but doesn't update existing ones
const isUpdateSnapshots = config.snapshotOptions.updateSnapshot === 'all'
// Provide the update flag to tests
provide('vitest:updateSnapshots', isUpdateSnapshots)
}
declare module 'vitest' {
export interface ProvidedContext {
'vitest:updateSnapshots': boolean
}
}

22
vitest.config.ts Normal file
View File

@ -0,0 +1,22 @@
import { defineConfig } from 'vitest/config'
import tsconfigPaths from 'vite-tsconfig-paths'
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
globals: true,
globalSetup: './src/test-setup/global-setup.ts',
environment: 'happy-dom',
setupFiles: ['./src/setupTests.ts'],
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
exclude: [
'**/node_modules/**',
'**/dist/**',
'**/cypress/**',
'**/.{idea,git,cache,output,temp}/**',
],
deps: {
external: [/playwright/],
},
},
})