Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/quality.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ Results and screenshots are written to `qa/shots/maintenance/` and `qa/shots/res

`qa:link` is a supervised two-app harness that may start installed provider CLIs. `qa:close` is a supervised two-app harness for the session close lifecycle (remote, offline, refused and bulk close, adoption focus, restart, Apply update); it starts the installed `claude` CLI in a throwaway repository and sends no prompts. `npm run qa:perf -- 4 --allow-live-agents` is a manual Windows benchmark and explicitly opts into real-agent startup. Performance QA refuses missing terminals, invisible workload, incomplete streaming/repaint activity and missing CPU samples instead of treating low CPU as success. It has not become a deterministic CI performance gate; that conversion remains a separate task.

`qa:images` (Windows) drives the cockpit image strip, full-size viewer and πŸ“Ž attach against an isolated home holding a transcript with a tool screenshot, a pasted image and a written image file; its fixture `claude` echoes what it is sent, so an attached path is read back off the terminal. `qa:update` (Windows) checks "Update all": a fixture `claude` prints Claude Code's failed-update footer, and the harness asserts that `claude update` ran with no session process alive and that every conversation was resumed by id. `qa:images-remote` (Windows) pairs a host and a viewer over real TLS and checks the same from the viewer: thumbnails and the full view cross the link, an attached JPEG lands on the host as PNG with the host path typed, and a non-image named `.png` or an unindexed id is refused over the link. None of these starts a real agent.

Do not run these against a live development conversation. Do not equate a short loopback test with sleep/resume, VPN changes or multi-day field validation.

## Release gates
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "devdeck",
"version": "1.38.1",
"version": "1.39.0",
"description": "Project command deck β€” at-a-glance state + Claude Code, Codex, and Antigravity resume",
"main": "dist/main/main.js",
"type": "commonjs",
Expand Down Expand Up @@ -39,7 +39,10 @@
"qa:perf": "npm run build && node qa/perf.mjs",
"qa:maintenance": "npm run build && node qa/maintenance.mjs",
"check:docs": "node scripts/check-docs.mjs",
"qa:session-sync": "npm run build && node qa/session-sync.mjs"
"qa:session-sync": "npm run build && node qa/session-sync.mjs",
"qa:images": "npm run build && node qa/images.mjs",
"qa:images-remote": "npm run build && node qa/images-remote.mjs",
"qa:update": "npm run build && node qa/claude-update.mjs"
},
"build": {
"appId": "com.soursea.devdeck",
Expand Down
107 changes: 107 additions & 0 deletions qa/claude-update.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// Verifies "update all Claude sessions" in the real app with a fixture `claude`: the button appears
// when the agent's screen says its update failed, every session is stopped, `claude update` runs with
// none of them alive, and the same conversations come back.
import { _electron as electron } from 'playwright';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';

// Windows harness (fixture claude.cmd on PATH). Run with npm run qa:update.
const root = resolve(process.argv[2] ?? dirname(dirname(fileURLToPath(import.meta.url))));
const shots = resolve(process.argv[3] ?? join(root, 'qa', 'shots', 'claude-update'));
mkdirSync(shots, { recursive: true });
const { closeElectron } = await import('file:///' + join(root, 'qa', 'electron-lifecycle.mjs').replace(/\\/g, '/'));

const sandbox = mkdtempSync(join(tmpdir(), 'devdeck-update-'));
const repo = join(sandbox, 'update-workspace');
const bin = join(sandbox, 'bin');
const profile = join(sandbox, 'profile');
const home = join(profile, 'home');
const log = join(sandbox, 'calls.log');
for (const p of [repo, bin, profile, home]) mkdirSync(p, { recursive: true });
execFileSync('git', ['init', '-q', '-b', 'qa-update'], { cwd: repo });
// Records every invocation with how many sessions were alive at that moment (a live session holds a
// lock file); a session prints Claude's failed-update footer so the cockpit offers the fix.
writeFileSync(join(bin, 'agent.js'), `
const fs = require('fs'); const path = require('path');
const log = ${JSON.stringify(log)}; const locks = ${JSON.stringify(sandbox)};
// A tree kill on Windows skips exit handlers, so a lock can outlive its process: ask the OS instead.
const running = (pid) => { try { process.kill(pid, 0); return true; } catch { return false; } };
const alive = () => fs.readdirSync(locks).filter((f) => f.startsWith('alive-') && running(Number(f.slice(6)))).length;
const args = process.argv.slice(2);
if (args[0] === 'update') { fs.appendFileSync(log, 'update alive=' + alive() + '\\n'); console.log('Successfully updated from 2.1.1 to 2.1.2'); process.exit(0); }
const me = path.join(locks, 'alive-' + process.pid); fs.writeFileSync(me, '');
const bye = () => { try { fs.unlinkSync(me); } catch {} process.exit(0); };
process.on('SIGINT', bye); process.on('SIGTERM', bye); process.on('exit', () => { try { fs.unlinkSync(me); } catch {} });
fs.appendFileSync(log, 'start ' + args.join(' ') + '\\n');
process.stdout.write('FIXTURE_READY\\r\\n \\u2717 Auto-update failed \\u00b7 Try claude doctor\\r\\n');
if (process.stdin.isTTY) process.stdin.setRawMode(true);
process.stdin.on('data', () => {});
setInterval(() => {}, 1 << 30);
`);
writeFileSync(join(bin, 'claude.cmd'), '@echo off\r\nnode "%~dp0agent.js" %*\r\n');

const sids = ['5a1b2c3d-1111-4222-8333-944455556666', '6b2c3d4e-1111-4222-8333-944455556666'];
const enc = repo.replace(/[^a-zA-Z0-9]/g, '-');
const tdir = join(home, '.claude', 'projects', enc);
mkdirSync(tdir, { recursive: true });
for (const sid of sids) writeFileSync(join(tdir, sid + '.jsonl'), JSON.stringify({ type: 'user', timestamp: new Date().toISOString(), cwd: repo, sessionId: sid, message: { role: 'user', content: 'hi' } }) + '\n');
writeFileSync(join(profile, 'state.json'), JSON.stringify({ projects: {}, settings: {
language: 'ko', folders: [{ path: repo, kind: 'repo' }], viewMode: 'list',
cockpitSessions: sids.map((sid, i) => ({ tileId: 'tile-up-' + i, projectPath: repo, name: 'update-workspace', sessionId: sid, agentId: 'claude', label: 'Update QA ' + i, lastActiveMs: Date.now() })),
} }));
const env = { ...process.env, HOME: home, USERPROFILE: home, CLAUDECODE: '', CLAUDE_CODE_SSE_PORT: '', CLAUDE_CODE_ENTRYPOINT: '' };
const pathKey = Object.keys(env).find((k) => k.toLowerCase() === 'path') || 'PATH';
env[pathKey] = bin + ';' + env[pathKey];
const app = await electron.launch({ args: ['.', '--user-data-dir=' + profile, '--no-sandbox', '--disable-gpu'], cwd: root, env });
const errors = [];
const check = async (name, fn) => { await fn(); console.log('PASS ' + name); };
try {
const win = await app.firstWindow();
win.setDefaultTimeout(20000);
win.on('pageerror', (e) => errors.push(String(e)));
win.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
await check('both sessions restore and run', async () => {
await win.waitForFunction(() => document.querySelectorAll('#shell-session-groups .shell-session').length === 2, null, { timeout: 30000 });
await win.locator('#shell-session-groups .shell-session').first().click();
await win.getByRole('button', { name: /λͺ¨λ‘ 볡원/ }).click();
const until = Date.now() + 20000;
const starts = () => (existsSync(log) ? readFileSync(log, 'utf8') : '').split(/\r?\n/).filter((l) => l.startsWith('start')).length;
while (Date.now() < until && starts() < 2) await new Promise((r) => setTimeout(r, 300));
assert.equal(starts(), 2);
});
await check('a failed background update offers "update all"', async () => {
await win.waitForFunction(() => [...document.querySelectorAll('#ck-header .ck-update')].some((b) => b.textContent.includes('λͺ¨λ‘ μ—…λ°μ΄νŠΈ (2)')), null, { timeout: 15000 });
await win.screenshot({ path: join(shots, 'u1-offer.png') });
});
await check('confirm β†’ sessions stop, claude update runs with none alive, both come back', async () => {
const startsBefore = readFileSync(log, 'utf8').split('\n').filter((l) => l.startsWith('start')).length;
await win.locator('#ck-header .ck-update', { hasText: 'λͺ¨λ‘ μ—…λ°μ΄νŠΈ' }).click();
await win.locator('.ck-confirm-ok').click();
await win.waitForFunction(() => document.querySelectorAll('#shell-session-groups .shell-session').length === 2, null, { timeout: 30000 });
const until = Date.now() + 30000;
let calls = '';
while (Date.now() < until) {
calls = readFileSync(log, 'utf8');
if (calls.includes('update alive=') && calls.split('\n').filter((l) => l.startsWith('start')).length >= startsBefore + 2) break;
await new Promise((r) => setTimeout(r, 500));
}
assert.match(calls, /update alive=0/, 'update ran with no session alive:\n' + calls);
const after = calls.slice(calls.indexOf('update alive='));
for (const sid of sids) assert.ok(after.includes(sid), 'resumed ' + sid + ':\n' + calls);
await win.screenshot({ path: join(shots, 'u2-after.png') });
});
assert.deepEqual(errors, [], 'no page/console errors');
console.log('done');
} catch (e) {
console.log('FAIL', e?.message ?? e);
try { await (await app.firstWindow()).screenshot({ path: join(shots, 'u-fail.png') }); } catch {}
console.log('calls:\n' + (existsSync(log) ? readFileSync(log, 'utf8') : '(none)'));
console.log('errors:', errors);
process.exitCode = 1;
} finally {
await closeElectron(app);
}
142 changes: 142 additions & 0 deletions qa/images-remote.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// Windows harness: a host and a paired viewer, real TLS, fixture CLI. From the viewer, a session on
// the host shows the images its transcript holds, opens them full size, and takes an attached JPEG
// (sent as PNG and typed as the host's path); the image methods refuse what they must over the link.
// Never starts a real agent. Run with npm run qa:images-remote.
import { _electron as electron } from 'playwright';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, copyFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { createServer } from 'node:net';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { closeElectron } from './electron-lifecycle.mjs';

const root = dirname(dirname(fileURLToPath(import.meta.url)));
const shots = resolve(process.env.DEVDECK_IMAGES_OUT || join(root, 'qa', 'shots', 'images-remote'));
mkdirSync(shots, { recursive: true });
const sandbox = mkdtempSync(join(tmpdir(), 'devdeck-images-remote-'));
const repo = join(sandbox, 'host-workspace');
const bin = join(sandbox, 'bin');
for (const p of [repo, bin]) mkdirSync(p, { recursive: true });
execFileSync('git', ['init', '-q', '-b', 'qa-remote-images'], { cwd: repo });
writeFileSync(join(bin, 'echo.js'), "process.stdout.write('FIXTURE_READY\\r\\n'); if (process.stdin.isTTY) process.stdin.setRawMode(true); process.stdin.on('data', (d) => process.stdout.write('GOT:' + d.toString().replace(/\\r/g, '') + '\\r\\n'));\n");
writeFileSync(join(bin, 'claude.cmd'), '@echo off\r\nnode "%~dp0echo.js"\r\n');
copyFileSync(join(root, 'build', 'icon.png'), join(repo, 'render.png'));
writeFileSync(join(repo, 'secret.png'), 'not an image: TOKEN=abc');

const sid = '7c3d4e5f-1111-4222-8333-944455556666';
const png = readFileSync(join(root, 'docs', 'demo', 'poster.png')).toString('base64');
const ts = new Date().toISOString();
const transcript = [
{ type: 'assistant', timestamp: ts, message: { role: 'assistant', content: [{ type: 'tool_use', id: 'toolu_1', name: 'mcp__playwright__browser_take_screenshot', input: {} }] } },
{ type: 'user', timestamp: ts, message: { role: 'user', content: [{ tool_use_id: 'toolu_1', type: 'tool_result', content: [{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: png } }] }] } },
{ type: 'assistant', timestamp: ts, message: { role: 'assistant', content: [{ type: 'tool_use', id: 'toolu_2', name: 'Write', input: { file_path: 'render.png', content: '' } }] } },
].map((l) => JSON.stringify(l)).join('\n') + '\n';

const apps = [];
async function launch(name, seed) {
const profile = join(sandbox, name.replace(/\W/g, '-')); const home = join(profile, 'home');
mkdirSync(home, { recursive: true });
const settings = { language: 'ko', machineName: name, folders: seed ? [{ path: repo, kind: 'repo' }] : [], viewMode: 'list' };
if (seed) {
const tdir = join(home, '.claude', 'projects', repo.replace(/[^a-zA-Z0-9]/g, '-'));
mkdirSync(tdir, { recursive: true });
writeFileSync(join(tdir, sid + '.jsonl'), transcript);
settings.cockpitSessions = [{ tileId: 'tile-remote-img', projectPath: repo, name: 'host-workspace', sessionId: sid, agentId: 'claude', label: 'Remote images QA', lastActiveMs: Date.now() }];
}
writeFileSync(join(profile, 'state.json'), JSON.stringify({ projects: {}, settings }));
const env = { ...process.env, HOME: home, USERPROFILE: home, CLAUDECODE: '', CLAUDE_CODE_SSE_PORT: '', CLAUDE_CODE_ENTRYPOINT: '' };
const pathKey = Object.keys(env).find((k) => k.toLowerCase() === 'path') || 'PATH';
env[pathKey] = bin + ';' + env[pathKey];
const app = await electron.launch({ args: ['.', '--user-data-dir=' + profile, '--no-sandbox', '--disable-gpu'], cwd: root, env });
apps.push(app);
const win = await app.firstWindow(); win.setDefaultTimeout(20000);
await app.evaluate(({ app: a }) => {
const require = process.getBuiltinModule('module').createRequire(a.getAppPath() + '/package.json');
require(a.getAppPath() + '/dist/main/link/portMap.js').mapPort = async () => ({ state: 'not-found' }); // no router mappings from a test
});
return { app, win };
}
const freePort = () => new Promise((res, rej) => { const s = createServer(); s.on('error', rej); s.listen(0, '127.0.0.1', () => { const p = s.address().port; s.close(() => res(p)); }); });
const errors = [];
const check = async (name, fn) => { await fn(); console.log('PASS ' + name); };
const rowsInclude = (win, text) => win.waitForFunction((t) => [...document.querySelectorAll('.ck-term.show .xterm-rows')].some((r) => r.textContent.replace(/\s/g, '').includes(t)), text, { timeout: 15000 });

try {
if (process.platform !== 'win32') { console.log('SKIP: Windows native PTY required'); process.exit(0); }
const host = await launch('Image host', true);
host.win.on('pageerror', (e) => errors.push('host: ' + e));
await host.win.waitForSelector('#shell-session-groups .shell-session', { timeout: 30000 });
await host.win.locator('#shell-session-groups .shell-session').first().click();
await rowsInclude(host.win, 'FIXTURE_READY');
await host.win.evaluate((p) => window.devdeck.link.setPort(p), await freePort());
const viewer = await launch('Image viewer', false);
viewer.win.on('pageerror', (e) => errors.push('viewer: ' + e));
viewer.win.on('console', (m) => { if (m.type() === 'error') errors.push('viewer: ' + m.text()); });
const status = await host.win.evaluate(() => window.devdeck.link.createInvite(['observe', 'control', 'spawn', 'write']));
assert.ok(status.listening && status.invite?.code, 'host is listening with an invite');
const added = await viewer.win.evaluate((code) => window.devdeck.link.addMachine(code), status.invite.code);
assert.equal(added.ok, true);
const machineId = status.machineId;

await check('the viewer opens the host session and sees its image count', async () => {
await viewer.win.waitForSelector('#shell-session-groups .shell-session', { timeout: 30000 });
await viewer.win.locator('#shell-session-groups .shell-session').first().click();
await viewer.win.waitForFunction(() => document.querySelector('#ck-header')?.textContent.includes('Remote images QA'));
await viewer.win.waitForFunction(() => document.querySelector('#ck-header .ck-img-count')?.textContent === '2', null, { timeout: 40000 });
});

await check('thumbnails and the full view travel over the link', async () => {
await viewer.win.locator('#ck-header button[aria-pressed]').click();
await viewer.win.waitForFunction(() => document.querySelectorAll('#ck-images .ck-img-thumb img[src^="data:image"]').length === 2);
await viewer.win.locator('#ck-images .ck-img-thumb.kind-screenshot').click();
await viewer.win.waitForFunction(() => document.querySelector('.ck-img-viewer .ck-img-stage img')?.getAttribute('src')?.startsWith('data:image'));
await viewer.win.screenshot({ path: join(shots, 'viewer-full.png') });
await viewer.win.keyboard.press('Escape');
});

await check('a JPEG attached on the viewer lands on the host as PNG and its host path is typed', async () => {
const jpg = join(sandbox, 'photo.jpg');
await host.app.evaluate(({ nativeImage }, [src, dst]) => {
require('node:fs').writeFileSync(dst, nativeImage.createFromPath(src).toJPEG(80));
}, [join(root, 'build', 'icon.png'), jpg]).catch(async () => {
// `require` is not in scope inside evaluate on every Electron build; fall back to the module loader.
await host.app.evaluate(({ nativeImage, app: a }, [src, dst]) => {
const req = process.getBuiltinModule('module').createRequire(a.getAppPath() + '/package.json');
req('node:fs').writeFileSync(dst, nativeImage.createFromPath(src).toJPEG(80));
}, [join(root, 'build', 'icon.png'), jpg]);
});
const chooser = viewer.win.waitForEvent('filechooser');
await viewer.win.locator('#ck-header .ck-act').filter({ has: viewer.win.locator('svg') }).and(viewer.win.locator('[title*="이미지 첨뢀"]')).click();
await (await chooser).setFiles(jpg);
await rowsInclude(viewer.win, 'devdeck-paste-');
const typed = await host.win.evaluate(() => [...document.querySelectorAll('.ck-term.show .xterm-rows')].map((r) => r.textContent).join('\n'));
const m = /GOT:(\S*devdeck-paste-[0-9a-f-]+\.png)/.exec(typed.replace(/\n/g, ''));
assert.ok(m, 'host typed a pasted-image path:\n' + typed);
const head = readFileSync(m[1]).subarray(0, 8);
assert.equal(head.toString('hex'), '89504e470d0a1a0a', 'the file on the host is a PNG');
await viewer.win.screenshot({ path: join(shots, 'viewer-attached.png') });
});

await check('over the link, a real image is served and a fake .png or unindexed id is refused', async () => {
const r = await viewer.win.evaluate(async ([id, path]) => {
const m = window.devdeck.machine(id).cockpit;
return {
real: !!(await m.imageFile(path, 'render.png', 'thumb')),
fake: await m.imageFile(path, 'secret.png', 'thumb'),
unindexed: await m.sessionImage(path, '7c3d4e5f-1111-4222-8333-944455556666', 'f:secret.png', 'full'),
};
}, [machineId, repo]);
assert.deepEqual(r, { real: true, fake: null, unindexed: null });
});

assert.deepEqual(errors, [], 'no page/console errors');
console.log('done');
} catch (e) {
console.log('FAIL', e?.message ?? e);
console.log('errors:', errors);
process.exitCode = 1;
} finally {
for (const a of apps) await closeElectron(a).catch(() => {});
}
Loading
Loading