Files
modeling-app/src/machines/modifyWithTTC.test.ts
Kurt Hutten 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

860 lines
27 KiB
TypeScript

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
}
)
})