@hydraharness/harness-desktop 0.1.1-rc.8 → 0.1.1-rc.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/main.cjs +147 -6
- package/package.json +7 -7
- package/tests/snapshots/files-operations.expected.json +13 -0
package/main.cjs
CHANGED
|
@@ -17,7 +17,8 @@ const CLI_ENTRY = process.env.HYDRA_DESKTOP_CLI_ENTRY || join(require.resolve('@
|
|
|
17
17
|
const BROWSER_ENTRY = join(require.resolve('@hydraharness/harness-browser-electron/package.json'), '..', 'electron-app', 'main.cjs')
|
|
18
18
|
const DESKTOP_ICON = join(__dirname, 'assets', process.platform === 'win32' ? 'hydra.ico' : 'hydra.png')
|
|
19
19
|
const SMOKE_ZOOM = process.argv.includes('--smoke-zoom')
|
|
20
|
-
const
|
|
20
|
+
const SMOKE_FILES = process.argv.includes('--smoke-files')
|
|
21
|
+
const SMOKE = process.argv.includes('--smoke') || SMOKE_ZOOM || SMOKE_FILES
|
|
21
22
|
const HOST_READY_TIMEOUT_MS = 90_000
|
|
22
23
|
const HOST_REQUEST_TIMEOUT_MS = 15_000
|
|
23
24
|
const HOST_SHUTDOWN_TIMEOUT_MS = 7_000
|
|
@@ -565,11 +566,18 @@ async function renameWorkspaceEntry(root, target, newName) {
|
|
|
565
566
|
if (currentPath === rootCanonical) throw new Error('cannot rename workspace root')
|
|
566
567
|
const parent = dirname(currentPath)
|
|
567
568
|
const destination = join(parent, newName)
|
|
568
|
-
const targetStat = await stat(currentPath)
|
|
569
|
+
const targetStat = await stat(currentPath, { bigint: true })
|
|
569
570
|
if (existsSync(destination)) throw new Error('a file or folder with this name already exists')
|
|
570
571
|
await rename(currentPath, destination)
|
|
571
572
|
const newConfined = await confinedPath(root, destination)
|
|
572
|
-
|
|
573
|
+
const version = targetStat.isFile() ? relocatedFileVersion(targetStat, await stat(newConfined, { bigint: true })) : undefined
|
|
574
|
+
return { oldPath: currentPath, path: newConfined, name: newName, directory: targetStat.isDirectory(), version }
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function relocatedFileVersion(before, after) {
|
|
578
|
+
// Rename may change ctime; preserve conflicts for changed file identity, size, or modification time.
|
|
579
|
+
if (before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeNs !== after.mtimeNs) return undefined
|
|
580
|
+
return { before: fileVersion(before), after: fileVersion(after) }
|
|
573
581
|
}
|
|
574
582
|
|
|
575
583
|
async function moveWorkspaceEntry(root, target, destinationTarget) {
|
|
@@ -578,7 +586,7 @@ async function moveWorkspaceEntry(root, target, destinationTarget) {
|
|
|
578
586
|
const rootCanonical = await realpath(root)
|
|
579
587
|
if (currentPath === rootCanonical) throw new Error('cannot move workspace root')
|
|
580
588
|
if (!(await stat(destinationDirectory)).isDirectory()) throw new Error('destination path is not a directory')
|
|
581
|
-
const targetStat = await stat(currentPath)
|
|
589
|
+
const targetStat = await stat(currentPath, { bigint: true })
|
|
582
590
|
if (targetStat.isDirectory() && (destinationDirectory === currentPath
|
|
583
591
|
|| destinationDirectory.startsWith(`${currentPath}${sep}`))) {
|
|
584
592
|
throw new Error('cannot move a folder into itself')
|
|
@@ -587,7 +595,8 @@ async function moveWorkspaceEntry(root, target, destinationTarget) {
|
|
|
587
595
|
if (existsSync(destination)) throw new Error('a file or folder with this name already exists')
|
|
588
596
|
await rename(currentPath, destination)
|
|
589
597
|
const newConfined = await confinedPath(root, destination)
|
|
590
|
-
|
|
598
|
+
const version = targetStat.isFile() ? relocatedFileVersion(targetStat, await stat(newConfined, { bigint: true })) : undefined
|
|
599
|
+
return { oldPath: currentPath, path: newConfined, name: basename(newConfined), directory: targetStat.isDirectory(), version }
|
|
591
600
|
}
|
|
592
601
|
|
|
593
602
|
async function deleteWorkspaceEntry(root, target) {
|
|
@@ -1117,6 +1126,133 @@ async function smokeZoomShortcuts() {
|
|
|
1117
1126
|
}
|
|
1118
1127
|
}
|
|
1119
1128
|
|
|
1129
|
+
async function smokeFiles() {
|
|
1130
|
+
const root = smokeWorkspace.root
|
|
1131
|
+
await mkdir(join(root, 'files-src'))
|
|
1132
|
+
await mkdir(join(root, 'files-target'))
|
|
1133
|
+
mainWindow.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'P', modifiers: ['control'] })
|
|
1134
|
+
mainWindow.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'P', modifiers: ['control'] })
|
|
1135
|
+
await waitForRenderer('Files root', `document.querySelector('[data-files-root]')?.getAttribute('data-files-root') === ${JSON.stringify(root)}`)
|
|
1136
|
+
await waitForRenderer('Files onboarding', `(() => {
|
|
1137
|
+
const dialogs = [...document.querySelectorAll('[role="dialog"]')]
|
|
1138
|
+
for (const dialog of dialogs) {
|
|
1139
|
+
const title = dialog.getAttribute('aria-label') ?? document.getElementById(dialog.getAttribute('aria-labelledby'))?.textContent
|
|
1140
|
+
const label = title === 'Internal Testing Notice' ? 'Continue' : title === 'Settings' ? 'Close' : undefined
|
|
1141
|
+
if (label) [...dialog.querySelectorAll('button')].find(button => button.textContent.trim() === label)?.click()
|
|
1142
|
+
}
|
|
1143
|
+
return dialogs.length === 0
|
|
1144
|
+
})()`)
|
|
1145
|
+
await selectControl('Refresh files')
|
|
1146
|
+
const row = name => `[...document.querySelectorAll('[aria-label="Workspace files"] button')].find(node => node.textContent.trim() === ${JSON.stringify(name)})`
|
|
1147
|
+
const clickRow = async name => {
|
|
1148
|
+
await waitForRenderer(`Files row ${name}`, row(name))
|
|
1149
|
+
await mainWindow.webContents.executeJavaScript(`${row(name)}.click()`)
|
|
1150
|
+
}
|
|
1151
|
+
const fill = async (label, value) => {
|
|
1152
|
+
await waitForRenderer(`Files input ${label}`, `document.querySelector('[aria-label=${JSON.stringify(label)}]')`)
|
|
1153
|
+
await mainWindow.webContents.executeJavaScript(`(() => {
|
|
1154
|
+
const input = document.querySelector('[aria-label=${JSON.stringify(label)}]')
|
|
1155
|
+
const prototype = input instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype
|
|
1156
|
+
Object.getOwnPropertyDescriptor(prototype, 'value').set.call(input, ${JSON.stringify(value)})
|
|
1157
|
+
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
1158
|
+
})()`)
|
|
1159
|
+
}
|
|
1160
|
+
const contextAction = async (name, action) => {
|
|
1161
|
+
await waitForRenderer(`Files row ${name}`, row(name))
|
|
1162
|
+
await mainWindow.webContents.executeJavaScript(`${row(name)}.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, clientX: 400, clientY: 200 }))`)
|
|
1163
|
+
await waitForRenderer(`Files menu ${action}`, `document.querySelector('[aria-label="File options"]')`)
|
|
1164
|
+
await mainWindow.webContents.executeJavaScript(`[...document.querySelectorAll('[role="menuitem"]')].find(node => node.textContent.trim() === ${JSON.stringify(action)}).click()`)
|
|
1165
|
+
}
|
|
1166
|
+
const save = async name => {
|
|
1167
|
+
await waitForRenderer('Files Save enabled', `document.querySelector('[aria-label="File editor"] [aria-label="Save"]')?.disabled === false`)
|
|
1168
|
+
await selectControl('Save', '[aria-label="File editor"]')
|
|
1169
|
+
await waitForRenderer(`Files ${name} saved`, `!document.querySelector('[aria-label="Unsaved changes"]') && !document.querySelector('[aria-label="File editor"] [role="status"]')`)
|
|
1170
|
+
}
|
|
1171
|
+
await contextAction('files-src', 'New File')
|
|
1172
|
+
await fill('New file name', 'a.ts')
|
|
1173
|
+
await selectControl('Create file')
|
|
1174
|
+
await waitForRenderer('Files created editor', `document.querySelector('[aria-label="Editor for a.ts"]')`)
|
|
1175
|
+
if (!existsSync(join(root, 'files-src', 'a.ts')) || existsSync(join(root, 'a.ts'))) throw new Error('Files context creation used the wrong parent')
|
|
1176
|
+
await fill('Editor for a.ts', 'renamed draft\n')
|
|
1177
|
+
await contextAction('files-src', 'Rename')
|
|
1178
|
+
await fill('Rename files-src', 'files-renamed')
|
|
1179
|
+
await selectControl('Confirm rename')
|
|
1180
|
+
await waitForRenderer('Files renamed editor path', `[...document.querySelectorAll('[role="tab"]')].some(node => node.title === ${JSON.stringify(join(root, 'files-renamed', 'a.ts'))})`)
|
|
1181
|
+
await save('a.ts')
|
|
1182
|
+
if (await readFile(join(root, 'files-renamed', 'a.ts'), 'utf8') !== 'renamed draft\n') throw new Error('Files renamed descendant did not save')
|
|
1183
|
+
await mainWindow.webContents.executeJavaScript(`(() => {
|
|
1184
|
+
const source = ${row('files-renamed')}
|
|
1185
|
+
const target = ${row('files-target')}
|
|
1186
|
+
const dataTransfer = new DataTransfer()
|
|
1187
|
+
source.dispatchEvent(new DragEvent('dragstart', { bubbles: true, dataTransfer }))
|
|
1188
|
+
target.dispatchEvent(new DragEvent('drop', { bubbles: true, dataTransfer }))
|
|
1189
|
+
})()`)
|
|
1190
|
+
const moved = join(root, 'files-target', 'files-renamed')
|
|
1191
|
+
await waitForRenderer('Files moved editor path', `[...document.querySelectorAll('[role="tab"]')].some(node => node.title === ${JSON.stringify(join(moved, 'a.ts'))})`)
|
|
1192
|
+
await fill('Editor for a.ts', 'moved draft\n')
|
|
1193
|
+
await save('a.ts')
|
|
1194
|
+
await clickRow('files-target')
|
|
1195
|
+
await contextAction('a.ts', 'Rename')
|
|
1196
|
+
await fill('Rename a.ts', 'b.ts')
|
|
1197
|
+
await selectControl('Confirm rename')
|
|
1198
|
+
await waitForRenderer('Files renamed file editor', `document.querySelector('[aria-label="Editor for b.ts"]')`)
|
|
1199
|
+
await fill('Editor for b.ts', 'file renamed draft\n')
|
|
1200
|
+
await save('b.ts')
|
|
1201
|
+
await contextAction('files-renamed', 'New File')
|
|
1202
|
+
await fill('New file name', 'c.ts')
|
|
1203
|
+
await selectControl('Create file')
|
|
1204
|
+
await waitForRenderer('Files second editor', `document.querySelector('[aria-label="Editor for c.ts"]')`)
|
|
1205
|
+
await fill('Editor for c.ts', 'second draft\n')
|
|
1206
|
+
await mainWindow.webContents.executeJavaScript(`[...document.querySelectorAll('[role="tab"]')].find(node => node.textContent.includes('b.ts')).click()`)
|
|
1207
|
+
await fill('Editor for b.ts', 'first saved\n')
|
|
1208
|
+
await writeFile(join(moved, 'c.ts'), 'external edit\n')
|
|
1209
|
+
await selectControl('Save all files')
|
|
1210
|
+
await waitForRenderer('Files partial Save All', `document.querySelector('[aria-label="File editor"] [role="alert"]')?.textContent.includes('file changed since it was opened')`)
|
|
1211
|
+
const partial = await mainWindow.webContents.executeJavaScript(`(() => {
|
|
1212
|
+
const dirty = name => [...document.querySelectorAll('[role="tab"]')].find(node => node.textContent.includes(name))?.querySelector('[title="Unsaved changes"]') !== null
|
|
1213
|
+
return { firstDirty: dirty('b.ts'), secondDirty: dirty('c.ts') }
|
|
1214
|
+
})()`)
|
|
1215
|
+
if (partial.firstDirty || !partial.secondDirty || await readFile(join(moved, 'b.ts'), 'utf8') !== 'first saved\n'
|
|
1216
|
+
|| await readFile(join(moved, 'c.ts'), 'utf8') !== 'external edit\n') throw new Error('Files partial save lost disk or draft state')
|
|
1217
|
+
const confinement = await mainWindow.webContents.executeJavaScript(`(async () => {
|
|
1218
|
+
const api = window.hydraDesktop.files
|
|
1219
|
+
const workspaceId = ${JSON.stringify(smokeWorkspace.workspaceId)}
|
|
1220
|
+
const rejected = promise => promise.then(() => false, () => true)
|
|
1221
|
+
return await Promise.all([
|
|
1222
|
+
rejected(api.move(${JSON.stringify(root)}, ${JSON.stringify(moved)}, workspaceId)),
|
|
1223
|
+
rejected(api.move(${JSON.stringify(moved)}, ${JSON.stringify(moved)}, workspaceId)),
|
|
1224
|
+
rejected(api.move(${JSON.stringify(join(moved, 'b.ts'))}, ${JSON.stringify(smokeWorkspace.outside)}, workspaceId)),
|
|
1225
|
+
rejected(api.move(${JSON.stringify(join(smokeWorkspace.outside, 'outside-only.txt'))}, ${JSON.stringify(root)}, workspaceId)),
|
|
1226
|
+
rejected(api.move(${JSON.stringify(join(moved, 'b.ts'))}, ${JSON.stringify(join(moved, 'c.ts'))}, workspaceId)),
|
|
1227
|
+
rejected(api.rename(${JSON.stringify(join(moved, 'b.ts'))}, 'c.ts', workspaceId)),
|
|
1228
|
+
rejected(api.delete(${JSON.stringify(root)}, workspaceId)),
|
|
1229
|
+
])
|
|
1230
|
+
})()`)
|
|
1231
|
+
if (!confinement.every(Boolean)) throw new Error('Files mutation confinement failed')
|
|
1232
|
+
const transcript = { contextCreate: 'files-src/a.ts', folderRename: 'files-renamed/a.ts', folderMove: 'files-target/files-renamed/a.ts', fileRename: 'files-target/files-renamed/b.ts', partialSave: { firstDirty: false, secondDirty: true, firstDisk: 'first saved\n', secondDisk: 'external edit\n' }, confined: confinement.length }
|
|
1233
|
+
const expected = JSON.parse(await readFile(join(__dirname, 'tests', 'snapshots', 'files-operations.expected.json'), 'utf8'))
|
|
1234
|
+
if (JSON.stringify(transcript) !== JSON.stringify(expected)) throw new Error(`Files transcript differs: ${JSON.stringify(transcript)}`)
|
|
1235
|
+
const screenshots = []
|
|
1236
|
+
const themeSource = nativeTheme.themeSource
|
|
1237
|
+
for (const theme of SMOKE_FILES ? ['light', 'dark'] : []) {
|
|
1238
|
+
nativeTheme.themeSource = theme
|
|
1239
|
+
await waitForRenderer(`Files ${theme} theme`, `document.body.hasAttribute('data-ds-dark-theme') === ${theme === 'dark'}`)
|
|
1240
|
+
await mainWindow.webContents.executeJavaScript('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))')
|
|
1241
|
+
const path = join(process.env.HYDRA_HOME || root, `desktop-files-${theme}.png`)
|
|
1242
|
+
await writeFile(path, (await mainWindow.webContents.capturePage()).toPNG())
|
|
1243
|
+
screenshots.push(path)
|
|
1244
|
+
}
|
|
1245
|
+
nativeTheme.themeSource = themeSource
|
|
1246
|
+
await mainWindow.webContents.executeJavaScript(`(() => {
|
|
1247
|
+
const confirm = window.confirm
|
|
1248
|
+
try {
|
|
1249
|
+
window.confirm = () => true
|
|
1250
|
+
document.querySelector('[aria-label="Close all files"]').click()
|
|
1251
|
+
} finally { window.confirm = confirm }
|
|
1252
|
+
})()`)
|
|
1253
|
+
return { transcript, screenshots }
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1120
1256
|
async function smoke() {
|
|
1121
1257
|
if (nativeImage.createFromPath(DESKTOP_ICON).isEmpty()) throw new Error('desktop Hydra icon is unavailable')
|
|
1122
1258
|
if (typeof smokeWorkspace?.workspaceId !== 'string') throw new Error('desktop smoke Workspace is unavailable')
|
|
@@ -1279,6 +1415,7 @@ async function smoke() {
|
|
|
1279
1415
|
if (!bomCrlf.equals(Buffer.from('\ufeffone\r\ntwo\r\nthree\r\n'))) {
|
|
1280
1416
|
throw new Error(`desktop Files changed BOM or CRLF bytes: ${JSON.stringify([...bomCrlf])}`)
|
|
1281
1417
|
}
|
|
1418
|
+
await smokeFiles()
|
|
1282
1419
|
await selectControl('Terminal')
|
|
1283
1420
|
await waitForHiddenBrowser('right Terminal')
|
|
1284
1421
|
await waitForRenderer('right Terminal panel', `document.querySelector('[aria-label="Right terminal"]:not([hidden])')`)
|
|
@@ -1596,6 +1733,7 @@ async function shutdown() {
|
|
|
1596
1733
|
app.whenReady().then(async () => {
|
|
1597
1734
|
try {
|
|
1598
1735
|
mainWindow = createWindow()
|
|
1736
|
+
if (SMOKE_FILES) mainWindow.webContents.setBackgroundThrottling(false)
|
|
1599
1737
|
browser = await installBrowserController(mainWindow)
|
|
1600
1738
|
installRendererIpc()
|
|
1601
1739
|
hostBaseUrl = await startHost()
|
|
@@ -1609,7 +1747,10 @@ app.whenReady().then(async () => {
|
|
|
1609
1747
|
}
|
|
1610
1748
|
})
|
|
1611
1749
|
if (SMOKE) {
|
|
1612
|
-
if (
|
|
1750
|
+
if (SMOKE_FILES) {
|
|
1751
|
+
const files = await smokeFiles()
|
|
1752
|
+
process.stdout.write(`${JSON.stringify({ event: 'desktop-files-smoke', ok: true, files })}\n`)
|
|
1753
|
+
} else if (SMOKE_ZOOM) {
|
|
1613
1754
|
await smokeZoomShortcuts()
|
|
1614
1755
|
process.stdout.write(`${JSON.stringify({ event: 'desktop-zoom-smoke', ok: true })}\n`)
|
|
1615
1756
|
} else {
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hydraharness/harness-desktop",
|
|
3
|
-
"version": "0.1.1-rc.
|
|
3
|
+
"version": "0.1.1-rc.9",
|
|
4
4
|
"description": "Windows-first Electron shell for the existing Hydra Web renderer and controlled browser",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
7
7
|
},
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
|
10
|
-
"url": "git+https://github.com/
|
|
10
|
+
"url": "git+https://github.com/MaiHongPhong2010/Hydra-Harness.git",
|
|
11
11
|
"directory": "apps/desktop"
|
|
12
12
|
},
|
|
13
13
|
"main": "main.cjs",
|
|
@@ -21,18 +21,18 @@
|
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"node-pty": "1.2.0-beta.15",
|
|
23
23
|
"prettier": "^3.9.5",
|
|
24
|
-
"@hydraharness/harness-atomic-write": "^0.1.1-rc.
|
|
25
|
-
"@hydraharness/harness-browser-electron": "^0.1.1-rc.
|
|
26
|
-
"@hydraharness/harness-subprocess": "^0.1.1-rc.
|
|
24
|
+
"@hydraharness/harness-atomic-write": "^0.1.1-rc.9",
|
|
25
|
+
"@hydraharness/harness-browser-electron": "^0.1.1-rc.9",
|
|
26
|
+
"@hydraharness/harness-subprocess": "^0.1.1-rc.9"
|
|
27
27
|
},
|
|
28
28
|
"optionalDependencies": {
|
|
29
29
|
"electron": "^44.0.0"
|
|
30
30
|
},
|
|
31
31
|
"peerDependencies": {
|
|
32
|
-
"@hydraharness/harness": "^0.1.1-rc.
|
|
32
|
+
"@hydraharness/harness": "^0.1.1-rc.9"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
|
-
"@hydraharness/harness": "^0.1.1-rc.
|
|
35
|
+
"@hydraharness/harness": "^0.1.1-rc.9"
|
|
36
36
|
},
|
|
37
37
|
"scripts": {
|
|
38
38
|
"start": "electron .",
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"contextCreate": "files-src/a.ts",
|
|
3
|
+
"folderRename": "files-renamed/a.ts",
|
|
4
|
+
"folderMove": "files-target/files-renamed/a.ts",
|
|
5
|
+
"fileRename": "files-target/files-renamed/b.ts",
|
|
6
|
+
"partialSave": {
|
|
7
|
+
"firstDirty": false,
|
|
8
|
+
"secondDirty": true,
|
|
9
|
+
"firstDisk": "first saved\n",
|
|
10
|
+
"secondDisk": "external edit\n"
|
|
11
|
+
},
|
|
12
|
+
"confined": 7
|
|
13
|
+
}
|