@hydraharness/harness-desktop 0.1.1-rc.7 → 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 CHANGED
@@ -13,11 +13,12 @@ const { stripVTControlCharacters } = require('node:util')
13
13
  const { app, BrowserWindow, dialog, ipcMain, nativeImage, nativeTheme, shell, utilityProcess } = require('electron')
14
14
  const nodePty = require('node-pty')
15
15
 
16
- const CLI_ENTRY = join(require.resolve('@hydraharness/harness/package.json'), '..', 'lib', 'bin.js')
16
+ const CLI_ENTRY = process.env.HYDRA_DESKTOP_CLI_ENTRY || join(require.resolve('@hydraharness/harness/package.json'), '..', 'lib', 'bin.js')
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 SMOKE = process.argv.includes('--smoke') || SMOKE_ZOOM
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
- return { oldPath: currentPath, path: newConfined, name: newName, directory: targetStat.isDirectory() }
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
- return { oldPath: currentPath, path: newConfined, name: basename(newConfined), directory: targetStat.isDirectory() }
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 (SMOKE_ZOOM) {
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,33 +1,39 @@
1
1
  {
2
2
  "name": "@hydraharness/harness-desktop",
3
- "version": "0.1.1-rc.7",
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/MaiHongPhong1902/Hydra-Harness.git",
10
+ "url": "git+https://github.com/MaiHongPhong2010/Hydra-Harness.git",
11
11
  "directory": "apps/desktop"
12
12
  },
13
13
  "main": "main.cjs",
14
14
  "files": [
15
15
  "main.cjs",
16
16
  "preload.cjs",
17
- "assets"
17
+ "assets",
18
+ "tests/snapshots"
18
19
  ],
19
20
  "license": "MIT",
20
21
  "dependencies": {
21
22
  "node-pty": "1.2.0-beta.15",
22
23
  "prettier": "^3.9.5",
23
- "@hydraharness/harness": "^0.1.1-rc.7",
24
- "@hydraharness/harness-browser-electron": "^0.1.1-rc.7",
25
- "@hydraharness/harness-atomic-write": "^0.1.1-rc.7",
26
- "@hydraharness/harness-subprocess": "^0.1.1-rc.7"
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
+ "peerDependencies": {
32
+ "@hydraharness/harness": "^0.1.1-rc.9"
33
+ },
34
+ "devDependencies": {
35
+ "@hydraharness/harness": "^0.1.1-rc.9"
36
+ },
31
37
  "scripts": {
32
38
  "start": "electron .",
33
39
  "smoke": "electron . --smoke",
@@ -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
+ }
@@ -0,0 +1,44 @@
1
+ [
2
+ {
3
+ "terminal": "right",
4
+ "cwd": "."
5
+ },
6
+ {
7
+ "terminal": "bottom-1-1",
8
+ "cwd": "."
9
+ },
10
+ {
11
+ "terminal": "right-2",
12
+ "cwd": "."
13
+ },
14
+ {
15
+ "terminal": "bottom-1-2",
16
+ "cwd": "."
17
+ },
18
+ {
19
+ "terminal": "bottom-1-2-2",
20
+ "cwd": "."
21
+ },
22
+ {
23
+ "terminal": "bottom-1-3",
24
+ "label": "Terminal 2"
25
+ },
26
+ {
27
+ "action": "trash-close",
28
+ "tabs": [
29
+ "Terminal"
30
+ ]
31
+ },
32
+ {
33
+ "terminal": "right-4-2",
34
+ "cwd": "."
35
+ },
36
+ {
37
+ "terminal": "right-9001",
38
+ "cwd": "workspace B"
39
+ },
40
+ {
41
+ "terminal": "right-9001",
42
+ "cwd": "workspace C"
43
+ }
44
+ ]
@@ -0,0 +1,9 @@
1
+ Ctrl+=: 120%
2
+ Ctrl+-: 100%
3
+ Ctrl+Shift+=: 120%
4
+ Ctrl+0: 100%
5
+ Ctrl+-: 83%
6
+ Ctrl+0: 100%
7
+ Ctrl+Alt+=: 100%
8
+ Ctrl+Shift+-: 100%
9
+ Titlebar and Review: 4 window sizes, 6 zoom levels, normal and expanded panels