Files
ycode/scripts/verify-three-modules.cjs
T

352 lines
26 KiB
JavaScript

// Production Electron integration with isolated data and a localhost-only model.
const assert = require('node:assert/strict')
const fs = require('node:fs/promises')
const path = require('node:path')
if (!process.versions.electron) {
;(async () => {
await fs.mkdir(path.resolve('.tmp'), { recursive: true })
const directory = await fs.mkdtemp(path.resolve('.tmp/three-modules-'))
const env = { ...process.env, YCODE_QA_DIRECTORY: directory,
YCODE_USER_DATA_DIR: path.join(directory, 'data'), YCODE_OMP_HOME: path.join(directory, 'home'),
YCODE_DISABLE_GPU: '1', YCODE_QA_OFFSCREEN: '1', YCODE_DISABLE_SYSTEM_INTEGRATION: '1' }
await Promise.all(['data', 'home', 'workspace'].map(name => fs.mkdir(path.join(directory, name))))
delete env.ELECTRON_RUN_AS_NODE
delete env.ELECTRON_RENDERER_URL
const child = require('node:child_process').spawn(require('electron'),
['--no-sandbox', '--disable-gpu-sandbox', __filename],
{ env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
let log = ''
child.stdout.on('data', bytes => { log += bytes; process.stdout.write(bytes) })
child.stderr.on('data', bytes => { log += bytes })
const timer = setTimeout(() => child.kill(), 120_000)
child.once('exit', async code => {
clearTimeout(timer)
await fs.writeFile(path.join(directory, 'process.log'), log)
console.log(JSON.stringify({ directory, exitCode: code }))
if (code) console.error(log.slice(-12000))
process.exitCode = code ?? 1
})
})().catch(error => { console.error(error); process.exitCode = 1 })
} else {
const { app, BrowserWindow } = require('electron')
const { pathToFileURL } = require('node:url')
const { createServer } = require('node:http')
const directory = process.env.YCODE_QA_DIRECTORY
const relative = path.relative(path.resolve('.tmp'), directory || '')
assert.ok(relative.startsWith('three-modules-') && !relative.includes(path.sep) && !path.isAbsolute(relative))
app.setPath('home', path.join(directory, 'home'))
const checks = []
const requests = []
const errors = []
let win, server, releaseManual
const manualGate = new Promise(resolve => { releaseManual = resolve })
const read = source => win.webContents.executeJavaScript(source)
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
const wait = async (operation, label) => {
for (let attempt = 0; attempt < 650; attempt++) {
const result = await operation()
if (result) return result
await pause(40)
}
throw Error(`Timeout: ${label}`)
}
const waitUi = source => wait(() => read(source), source)
const check = label => { checks.push(label); console.log(`PASS ${label}`) }
const invoke = (namespace, method, ...args) => read(`window.ycode[${JSON.stringify(namespace)}][${JSON.stringify(method)}](...${JSON.stringify(args)})`)
const fill = (selector, value, tag = 'HTMLInputElement', event = 'input') => read(`(() => {
const input = document.querySelector(${JSON.stringify(selector)});
Object.getOwnPropertyDescriptor(${tag}.prototype, 'value').set.call(input, ${JSON.stringify(value)});
input.dispatchEvent(new Event(${JSON.stringify(event)}, {bubbles:true}));
})()`)
const sse = (body, delta, finishReason = null) => `data: ${JSON.stringify({ id: 'three-modules-response', object: 'chat.completion.chunk', created: 1,
model: body.model, choices: [{ index: 0, delta, finish_reason: finishReason }] })}\n\n`
const navigate = async label => {
await read(`Array.from(document.querySelectorAll('.conversation-list-tab-button')).find(el => el.textContent.trim() === ${JSON.stringify(label)}).click()`)
}
;(async () => {
server = createServer(async (req, res) => {
let raw = ''
for await (const chunk of req) raw += chunk
const body = JSON.parse(raw || '{}')
const prompt = [...(body.messages || [])].reverse().find(message => message.role === 'user')
const text = JSON.stringify(prompt?.content ?? '')
requests.push({ model: body.model, text, tools: body.tools?.map(tool => tool.function?.name) })
if (text.includes('FORCED_FAILURE')) {
res.writeHead(400, { 'content-type': 'application/json' })
res.end(JSON.stringify({ error: { message: 'CONTROLLED_UPSTREAM_FAILURE' } }))
return
}
res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' })
if (text.includes('PROJECT_FULL_ACCESS_COMMAND') && !body.messages.some(message => message.role === 'tool')) {
const tool = body.tools?.find(item => item.function?.name === 'execute_command')
assert.ok(tool, 'actual model tool schema must expose command execution')
const args = { command: `node -e "require('fs').writeFileSync('project-full-access.txt','FULL_ACCESS_OK')"` }
res.write(sse(body, { role: 'assistant', tool_calls: [{ index: 0, id: 'three-modules-command', type: 'function',
function: { name: tool.function.name, arguments: JSON.stringify(args) } }] }))
res.write(sse(body, {}, 'tool_calls'))
} else if (text.includes('SCHEDULE_WRITE_ARTIFACT') && !body.messages.some(message => message.role === 'tool')) {
const tool = body.tools?.find(item => /^(write|write_file|write_to_file)$/i.test(item.function?.name))
assert.ok(tool, 'actual model tool schema must expose file writing')
const args = { path: 'scheduled-report.md', file_path: 'scheduled-report.md', content: '# Scheduled artifact\n\nGenerated through the real tool loop.\n' }
res.write(sse(body, { role: 'assistant', tool_calls: [{ index: 0, id: 'three-modules-write', type: 'function',
function: { name: tool.function.name, arguments: JSON.stringify(args) } }] }))
res.write(sse(body, {}, 'tool_calls'))
} else {
res.write(sse(body, { role: 'assistant', content: text.includes('MANUAL_HELD') ? 'MANUAL_PARTIAL' : 'SCHEDULE_SUCCEEDED' }))
if (text.includes('MANUAL_HELD')) await manualGate
if (res.destroyed) return
res.write(sse(body, { content: '\nFINAL_PERSISTED_RESULT' }))
res.write(sse(body, {}, 'stop'))
}
res.end('data: [DONE]\n\n')
})
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href)
await app.whenReady()
await wait(async () => {
win = BrowserWindow.getAllWindows().find(item => item.getTitle() === 'YCode')
return win && !win.webContents.isLoading() && await read('!!window.ycode?.automation && !!document.querySelector(".chat-pane")')
}, 'production renderer')
win.setSize(1336, 902)
win.webContents.on('console-message', (...args) => {
const details = args[0]
const level = typeof details === 'object' ? details.level : args[1]
const message = typeof details === 'object' ? details.message : args[2]
if (level === 'error' || Number(level) >= 3) errors.push(message)
})
const registry = await invoke('workspace', 'attach', path.join(directory, 'workspace'))
const workspaceId = registry.registry?.selectedWorkspaceId ?? registry.selectedWorkspaceId
assert.ok(workspaceId, JSON.stringify(registry))
await invoke('models', 'save', { id: 'three-modules-model', name: 'Three modules QA', providerId: 'custom',
model: 'three-modules-upstream', baseUrl: `http://127.0.0.1:${server.address().port}/v1`,
authMode: 'none', supportsToolCall: true, maxToolRounds: 5,
supportsReasoning: true, defaultEffort: 'medium', supportedEfforts: ['low', 'medium', 'high', 'xhigh', 'max'],
canDisableThinking: true })
await invoke('models', 'select', 'three-modules-model')
const project = await invoke('projects', 'create', { name: '三模块验收', instructions: 'QA_PROJECT_INSTRUCTION',
workspaceId, experts: [], skills: [], connectors: [] })
const definition = await invoke('automation', 'create', { name: '自动产物验收', prompt: 'SCHEDULE_WRITE_ARTIFACT',
schedule: { type: 'once', scheduledAt: new Date(Date.now() + 2500).toISOString(), timeZone: 'Asia/Shanghai' },
execution: { projectId: project.projectId, workspaceId, modelId: 'three-modules-model', permissionMode: 'full-access' } })
const completed = await wait(async () => (await invoke('automation', 'listRuns', { automationId: definition.id }))
.find(run => ['succeeded', 'failed', 'cancelled'].includes(run.status)), 'scheduled execution terminal')
assert.equal(completed.status, 'succeeded', JSON.stringify(completed))
assert.ok(completed.sessionId && completed.runId)
const messages = await invoke('session', 'messages', completed.sessionId)
assert.equal(messages.at(-1).status, 'done')
assert.ok(messages.at(-1).content.includes('FINAL_PERSISTED_RESULT'))
const session = (await invoke('session', 'list')).find(item => item.id === completed.sessionId)
assert.equal(session.projectId, project.projectId)
assert.equal(session.workspaceId, workspaceId)
assert.equal(session.modelId, 'three-modules-model')
await waitUi(`!!document.querySelector('[data-session-id="${completed.sessionId}"]')`)
assert.equal(await fs.readFile(path.join(directory, 'workspace', 'scheduled-report.md'), 'utf8'), '# Scheduled artifact\n\nGenerated through the real tool loop.\n')
check('scheduled task executes a real model/tool turn with frozen project, workspace and model; terminal follows persistence')
const manualDefinition = await invoke('automation', 'create', { name: '运行中验收', prompt: 'MANUAL_HELD',
schedule: { type: 'once', scheduledAt: new Date(Date.now() + 3600_000).toISOString(), timeZone: 'Asia/Shanghai' },
execution: { workspaceId, modelId: 'three-modules-model' } })
const beforeManual = Date.now()
const acknowledged = await invoke('automation', 'runNow', manualDefinition.id)
assert.equal(acknowledged.status, 'running')
assert.ok(Date.now() - beforeManual < 5000, 'runNow must acknowledge before the held response completes')
const running = await wait(async () => (await invoke('automation', 'listRuns', { automationId: manualDefinition.id })).find(run => run.sessionId), 'real session linked while running')
assert.equal(running.status, 'running')
assert.equal(await invoke('automation', 'runNow', manualDefinition.id).then(() => false, () => true), true)
check('manual execution acknowledges immediately, publishes real session while streaming and rejects overlapping execution')
releaseManual()
await wait(async () => (await invoke('automation', 'listRuns', { automationId: manualDefinition.id })).some(run => run.status === 'succeeded'), 'manual terminal')
await invoke('automation', 'setEnabled', manualDefinition.id, false)
assert.equal(await invoke('automation', 'runNow', manualDefinition.id).then(() => false, () => true), true)
const failing = await invoke('automation', 'create', { name: '失败验收', prompt: 'FORCED_FAILURE',
schedule: { type: 'once', scheduledAt: new Date(Date.now() + 3600_000).toISOString(), timeZone: 'UTC' },
execution: { workspaceId, modelId: 'three-modules-model' } })
await invoke('automation', 'runNow', failing.id)
const failure = await wait(async () => (await invoke('automation', 'listRuns', { automationId: failing.id })).find(run => run.status === 'failed'), 'actual upstream failure')
assert.ok(failure.error)
assert.equal((await invoke('session', 'messages', failure.sessionId)).at(-1).status, 'error')
check('upstream error is persisted as a failed automation and assistant error; paused task cannot run')
const snapshot = await invoke('library', 'getSnapshot')
const artifact = snapshot.artifacts.find(entry => entry.sessionId === completed.sessionId && entry.name === 'scheduled-report.md')
assert.ok(artifact, JSON.stringify(snapshot))
assert.equal(artifact.availability, 'available')
assert.equal(artifact.projectId, project.projectId)
assert.equal(snapshot.artifacts.length, 1, 'read-only source and unrelated workspace files must not be indexed')
await navigate('资料库')
await waitUi('!!document.querySelector(".library-page")')
await read(`Array.from(document.querySelectorAll('.library-nav-item')).find(el => el.textContent.includes('本地产物')).click()`)
await waitUi('!!document.querySelector(".library-file-row")')
await read('document.querySelector(".library-file-row button[aria-label^=更多操作]").click()')
await waitUi('!!document.querySelector(".library-file-menu .library-favorite")')
await read('document.querySelector(".library-favorite").click()')
await wait(async () => (await invoke('library', 'getSnapshot')).artifacts.find(entry => entry.id === artifact.id)?.favorite,
'favorite saved through visible more menu')
await fs.writeFile(path.join(directory, 'library-light.png'), (await win.webContents.capturePage()).toPNG())
await read('document.querySelector(".library-file-button").click()')
await waitUi('!!document.querySelector(".chat-pane") && document.body.innerText.includes("Scheduled artifact")')
const recent = await invoke('library', 'getSnapshot')
assert.ok(recent.artifacts[0].recentAt)
assert.equal(recent.artifacts[0].favorite, true)
check('library lists only the actual generated artifact and opens it through its source session; favorite/recent persist')
const loaded = new Promise(resolve => win.webContents.once('did-finish-load', resolve))
win.webContents.reload()
await loaded
await waitUi('!!window.ycode?.automation && !!document.querySelector(".conversation-list-tab-button")')
assert.equal((await invoke('library', 'getSnapshot')).artifacts[0].favorite, true)
assert.ok((await invoke('automation', 'list')).some(item => item.id === definition.id))
await navigate('定时任务')
await waitUi('!!document.querySelector(".automation-page")')
await waitUi('document.body.innerText.includes("自动产物验收")')
await fs.writeFile(path.join(directory, 'automations-light.png'), (await win.webContents.capturePage()).toPNG())
await read('localStorage.setItem("ycode:theme", "dark"); window.dispatchEvent(new StorageEvent("storage", {key:"ycode:theme",newValue:"dark"}))')
await waitUi('document.documentElement.dataset.theme === "dark"')
await pause(350)
await fs.writeFile(path.join(directory, 'automations-dark.png'), (await win.webContents.capturePage()).toPNG())
check('definitions, run records and library preferences survive renderer reload; real pages render in light/dark')
await read('document.querySelector(".automation-create-main").click()')
await waitUi('!!document.querySelector(".automation-editor-modal")')
await read('document.querySelector("button[aria-label=重复频率]").click()')
await waitUi('document.querySelectorAll(".atm-freq-menu-popover [role=option]").length === 7')
await read('Array.from(document.querySelectorAll(".atm-freq-menu-popover [role=option]")).find(el => el.textContent.trim() === "双周").click()')
await waitUi('!document.querySelector(".atm-freq-menu-popover")')
await fill('.automation-name-input', '表单验收')
await fill('.automation-prompt-input', 'FORM_DAILY_TASK', 'HTMLTextAreaElement')
await fs.writeFile(path.join(directory, 'automation-editor-dark.png'), (await win.webContents.capturePage()).toPNG())
await read('document.querySelector(".automation-editor-footer .automation-button--primary").click()')
await waitUi('!!document.querySelector(".automation-full-access-confirm")')
assert.equal(await read('document.querySelector("[data-testid=automation-full-access-confirm-ok]").disabled'), true)
await read('document.querySelector(".automation-full-access-confirm__checkbox input").click()')
await read('document.querySelector("[data-testid=automation-full-access-confirm-ok]").click()')
await waitUi(`!document.querySelector('.automation-editor-modal') && !!document.querySelector('button[aria-label="编辑 表单验收"]')`)
const formCreated = (await invoke('automation', 'list')).find(item => item.name === '表单验收')
assert.equal(formCreated.schedule.frequency, 'biweekly')
assert.ok(formCreated.schedule.rrule.includes('INTERVAL=2'))
assert.equal(formCreated.execution.modelId, 'three-modules-model')
await read(`document.querySelector('button[aria-label="编辑 表单验收"]').click()`)
await waitUi('!!document.querySelector(".automation-prompt-input")')
await fill('.automation-prompt-input', 'FORM_EDITED_TASK', 'HTMLTextAreaElement')
await read('document.querySelector(".automation-editor-footer .automation-button--primary").click()')
await waitUi('!document.querySelector(".automation-editor-modal")')
const formEdited = (await invoke('automation', 'list')).find(item => item.id === formCreated.id)
assert.equal(formEdited.prompt, 'FORM_EDITED_TASK')
assert.equal(formEdited.schedule.anchorAt, formCreated.schedule.anchorAt)
await read(`document.querySelector('button[aria-label="暂停 表单验收"]').click()`)
await waitUi(`!!document.querySelector('button[aria-label="启用 表单验收"]')`)
assert.equal((await invoke('automation', 'list')).find(item => item.id === formCreated.id).status, 'PAUSED')
await read(`document.querySelector('button[aria-label="更多操作 表单验收"]').click()`)
await waitUi('!!document.querySelector(".automation-task-menu")')
await read('Array.from(document.querySelectorAll(".automation-task-menu button")).find(el=>el.textContent.trim()==="删除").click()')
await waitUi('!!document.querySelector(".automation-confirm-dialog")')
await read('document.querySelector(".automation-confirm-actions .automation-button--danger").click()')
await waitUi(`!document.querySelector('.automation-confirm-dialog') && !document.querySelector('button[aria-label="编辑 表单验收"]')`)
assert.ok(!(await invoke('automation', 'list')).some(item => item.id === formCreated.id))
check('visible editor creates/edits a biweekly rule without moving its anchor, then pauses/deletes through real IPC')
await navigate('资料库')
await waitUi('!!document.querySelector(".library-page")')
await read(`Array.from(document.querySelectorAll('.library-nav-item')).find(el => el.textContent.includes('本地产物')).click()`)
await waitUi('!!document.querySelector(".library-file-row")')
await pause(350)
await fs.writeFile(path.join(directory, 'library-dark.png'), (await win.webContents.capturePage()).toPNG())
await read('document.querySelector(".conversation-list-tab-button-more").click()')
await waitUi('!!document.querySelector(".yc-project-more button")')
await read('document.querySelector(".yc-project-more button").click()')
await waitUi('!!document.querySelector(".project-grid__card[role=button]")')
await read('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))')
await read(`document.querySelector('.project-grid__card[role=button]')
.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))`)
await waitUi('!!document.querySelector("textarea[aria-label=项目任务输入]")')
await read('document.querySelector(".project-detail-view__project-composer .cr-model-selector__trigger").click()')
await waitUi('!!document.querySelector(".cr-model-selector__menu")')
await waitUi('!!document.querySelector(".cr-model-selector__menu .cr-model-selector__item[aria-selected=true]")')
await pause(220)
const modelPoint = await read(`(() => {
const item = document.querySelector('.cr-model-selector__menu .cr-model-selector__item[aria-selected="true"]')
if (!item) return null
const rect = item.getBoundingClientRect()
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }
})()`)
assert.ok(modelPoint)
win.webContents.sendInputEvent({ type: 'mouseMove', x: Math.round(modelPoint.x), y: Math.round(modelPoint.y) })
await waitUi('!!document.querySelector(".cr-model-selector__sub-menu-action")')
await pause(220)
const effortPoint = await read(`(() => {
const item = document.querySelector('.cr-model-selector__sub-menu-action')
const rect = item.getBoundingClientRect()
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }
})()`)
win.webContents.sendInputEvent({ type: 'mouseMove', x: Math.round(effortPoint.x), y: Math.round(effortPoint.y) })
await waitUi('!!document.querySelector(".cr-model-selector__sub-panel")')
await read(`Array.from(document.querySelectorAll('.cr-model-selector__sub-panel-item'))
.find(el => el.textContent.trim() === '极致').click()`)
await waitUi(`Array.from(document.querySelectorAll('.cr-model-selector__sub-panel-item'))
.some(el => el.textContent.trim() === '极致' && el.classList.contains('cr-model-selector__sub-panel-item--active'))`)
win.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' })
win.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' })
await waitUi('!document.querySelector(".cr-model-selector__menu")')
await read('document.querySelector(".project-detail-view__project-composer .cr-permission-setting").click()')
await waitUi('!!document.querySelector(".cr-permission-panel input[role=switch]")')
await read('document.querySelector(".cr-permission-panel input[role=switch]").click()')
await waitUi('!!document.querySelector(".permission-confirm-dialog")')
assert.equal(await read('document.querySelector(".permission-confirm-dialog .wb-composer__danger-button").disabled'), true)
await read('document.querySelector(".permission-confirm-acknowledgement").click()')
await read('document.querySelector(".permission-confirm-dialog .wb-composer__danger-button").click()')
await waitUi('document.querySelector(".project-detail-view__project-composer .cr-permission-setting")?.textContent.includes("允许完全访问")')
const projectSessionIdsBefore = new Set((await invoke('session', 'list')).map(item => item.id))
await fill('textarea[aria-label=项目任务输入]', 'PROJECT_FULL_ACCESS_COMMAND', 'HTMLTextAreaElement')
await read('document.querySelector(".project-detail-view__project-composer .cr-send-button").click()')
await waitUi('!document.querySelector(".project-detail-view") && document.body.innerText.includes("PROJECT_FULL_ACCESS_COMMAND") && document.body.innerText.includes("FINAL_PERSISTED_RESULT")')
const projectTask = (await invoke('session', 'list')).find(item => item.projectId === project.projectId && !projectSessionIdsBefore.has(item.id))
assert.ok(projectTask)
assert.equal((await invoke('session', 'messages', projectTask.id)).find(message => message.role === 'user').content, 'PROJECT_FULL_ACCESS_COMMAND')
assert.equal(projectTask.projectId, project.projectId)
assert.equal(projectTask.thinkingLevel, 'max')
assert.equal(await fs.readFile(path.join(directory, 'workspace', 'project-full-access.txt'), 'utf8'), 'FULL_ACCESS_OK')
check('project composer forwards max thinking and full-access, switches immediately, and executes a real privileged tool turn')
// A separate, unused directory avoids Windows locking an OMP process cwd.
const failingWorkspace = path.resolve(directory, 'first-send-failure-workspace')
assert.ok(failingWorkspace.startsWith(path.resolve(directory) + path.sep))
await fs.mkdir(failingWorkspace)
const failingRegistry = await invoke('workspace', 'attach', failingWorkspace)
await invoke('projects', 'create', { name: '失败恢复项目', instructions: '',
workspaceId: failingRegistry.selectedWorkspaceId, experts: [], skills: [], connectors: [] })
await read('document.querySelector(".conversation-list-tab-button-more").click()')
await waitUi('!!document.querySelector(".yc-project-more button")')
await read('document.querySelector(".yc-project-more button").click()')
await waitUi(`Array.from(document.querySelectorAll('.project-grid__card[role=button]')).some(el => el.textContent.includes('失败恢复项目'))`)
await read(`Array.from(document.querySelectorAll('.project-grid__card[role=button]')).find(el => el.textContent.includes('失败恢复项目')).click()`)
await waitUi('!!document.querySelector("textarea[aria-label=项目任务输入]")')
const beforeFailedSend = (await invoke('session', 'list')).length
await fs.rmdir(failingWorkspace)
try {
await fill('textarea[aria-label=项目任务输入]', 'RESTORE_PROJECT_DRAFT', 'HTMLTextAreaElement')
await read('document.querySelector(".project-detail-view__project-composer .cr-send-button").click()')
await waitUi('!document.querySelector(".project-detail-view") && document.querySelector(".wb-composer__input")?.value === "RESTORE_PROJECT_DRAFT"')
assert.equal((await invoke('session', 'list')).length, beforeFailedSend)
} finally { await fs.mkdir(failingWorkspace) }
check('project first-send failure restores the composer draft and does not leave an empty session')
assert.deepEqual(errors, [])
assert.ok(requests.every(request => request.model === 'three-modules-upstream'))
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result: 'PASS', checks, completed, failure, artifact, requests, rendererErrors: errors }, null, 2))
console.log(`THREE_MODULES_PASS ${directory}`)
server.closeAllConnections()
server.close()
app.exit(0)
})().catch(async error => {
console.error(error)
releaseManual?.()
if (win && !win.isDestroyed()) await fs.writeFile(path.join(directory, 'failure.png'), (await win.webContents.capturePage()).toPNG()).catch(() => {})
await fs.writeFile(path.join(directory, 'failure.json'), JSON.stringify({ error: String(error.stack || error), checks, requests, rendererErrors: errors }, null, 2))
server?.closeAllConnections()
server?.close()
app.exit(1)
})
}