@liduchuan/atlas-loop 0.0.0-stage → 0.1.0
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/README.md +94 -2
- package/SKILL.md +59 -0
- package/assets/viewer-shell.html +1385 -0
- package/bin/atlas-loop.mjs +93 -0
- package/package.json +40 -4
- package/references/schema.md +122 -0
- package/scripts/build_atlas.mjs +261 -0
- package/scripts/verify_atlas.mjs +316 -0
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { execSync } from 'node:child_process';
|
|
6
|
+
import { pathToFileURL } from 'node:url';
|
|
7
|
+
|
|
8
|
+
function parseArgs(argv) {
|
|
9
|
+
const args = {};
|
|
10
|
+
for (let i = 2; i < argv.length; i++) {
|
|
11
|
+
if (argv[i] === '--html') args.html = argv[++i];
|
|
12
|
+
}
|
|
13
|
+
return args;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function ensurePlaywrightCore() {
|
|
17
|
+
const verifierDir = path.join(os.tmpdir(), 'atlas-loop-verifier');
|
|
18
|
+
const entry = path.join(verifierDir, 'node_modules', 'playwright-core', 'index.mjs');
|
|
19
|
+
if (fs.existsSync(entry)) return entry;
|
|
20
|
+
|
|
21
|
+
fs.mkdirSync(verifierDir, { recursive: true });
|
|
22
|
+
fs.writeFileSync(
|
|
23
|
+
path.join(verifierDir, 'package.json'),
|
|
24
|
+
JSON.stringify({ name: 'atlas-loop-verifier', private: true, type: 'module' }),
|
|
25
|
+
'utf8'
|
|
26
|
+
);
|
|
27
|
+
try {
|
|
28
|
+
execSync('bun add --cwd "' + verifierDir + '" playwright-core', { stdio: 'pipe' });
|
|
29
|
+
} catch (_) {
|
|
30
|
+
execSync('npm install --prefix "' + verifierDir + '" playwright-core --no-save --silent', {
|
|
31
|
+
stdio: 'pipe',
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return entry;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function findLocalChromiumExecutable() {
|
|
38
|
+
const home = os.homedir();
|
|
39
|
+
const cacheDirs = [
|
|
40
|
+
path.join(home, 'Library', 'Caches', 'ms-playwright'),
|
|
41
|
+
path.join(home, '.cache', 'ms-playwright'),
|
|
42
|
+
];
|
|
43
|
+
for (const base of cacheDirs) {
|
|
44
|
+
if (!fs.existsSync(base)) continue;
|
|
45
|
+
const entries = fs.readdirSync(base).sort().reverse();
|
|
46
|
+
for (const dir of entries) {
|
|
47
|
+
if (dir.startsWith('chromium_headless_shell-')) {
|
|
48
|
+
const sub = path.join(base, dir);
|
|
49
|
+
for (const archDir of fs.readdirSync(sub)) {
|
|
50
|
+
const bin = path.join(sub, archDir, 'chrome-headless-shell');
|
|
51
|
+
if (fs.existsSync(bin)) return bin;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (dir.startsWith('chromium-')) {
|
|
55
|
+
const sub = path.join(base, dir);
|
|
56
|
+
for (const archDir of fs.readdirSync(sub)) {
|
|
57
|
+
const macBin = path.join(sub, archDir, 'Chromium.app', 'Contents', 'MacOS', 'Chromium');
|
|
58
|
+
const linuxBin = path.join(sub, archDir, 'chrome');
|
|
59
|
+
if (fs.existsSync(macBin)) return macBin;
|
|
60
|
+
if (fs.existsSync(linuxBin)) return linuxBin;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const sysCandidates = [
|
|
66
|
+
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
67
|
+
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
|
68
|
+
'/usr/bin/chromium',
|
|
69
|
+
'/usr/bin/chromium-browser',
|
|
70
|
+
'/usr/bin/google-chrome',
|
|
71
|
+
];
|
|
72
|
+
for (const c of sysCandidates) {
|
|
73
|
+
if (fs.existsSync(c)) return c;
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function assert(cond, msg) {
|
|
79
|
+
if (!cond) throw new Error(`Assertion failed: ${msg}`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function main() {
|
|
83
|
+
const args = parseArgs(process.argv);
|
|
84
|
+
if (!args.html) {
|
|
85
|
+
console.error('Usage: node verify_atlas.mjs --html <path-to-atlas.html>');
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
const htmlPath = path.resolve(args.html);
|
|
89
|
+
assert(fs.existsSync(htmlPath), `HTML file not found: ${htmlPath}`);
|
|
90
|
+
|
|
91
|
+
const pwEntry = ensurePlaywrightCore();
|
|
92
|
+
const { chromium } = await import(pathToFileURL(pwEntry).href);
|
|
93
|
+
const executablePath = findLocalChromiumExecutable();
|
|
94
|
+
|
|
95
|
+
const browser = await chromium.launch({
|
|
96
|
+
headless: true,
|
|
97
|
+
...(executablePath ? { executablePath } : {}),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const checks = {};
|
|
101
|
+
const pageErrors = [];
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
|
105
|
+
page.on('pageerror', (err) => pageErrors.push(String(err)));
|
|
106
|
+
|
|
107
|
+
await page.goto(pathToFileURL(htmlPath).href, { waitUntil: 'load' });
|
|
108
|
+
await page.waitForSelector('[data-testid="atlas-node-card"]', { timeout: 8000 });
|
|
109
|
+
|
|
110
|
+
// 1. Model badge & zero page errors
|
|
111
|
+
const modelText = (await page.locator('[data-testid="model-badge"]').textContent()) || '';
|
|
112
|
+
assert(modelText.trim().length > 3, 'Model badge is empty');
|
|
113
|
+
checks.modelDisplayed = modelText.trim();
|
|
114
|
+
|
|
115
|
+
// 2. Non-zero canvas & non-overlapping initial overview nodes
|
|
116
|
+
const canvasBox = await page.locator('[data-testid="atlas-canvas-wrap"]').boundingBox();
|
|
117
|
+
assert(canvasBox && canvasBox.width > 400 && canvasBox.height > 300, 'Canvas bounding box invalid');
|
|
118
|
+
|
|
119
|
+
const initialPositions = await page.evaluate(() => {
|
|
120
|
+
return Array.from(document.querySelectorAll('.react-flow__node')).map((el) => {
|
|
121
|
+
const r = el.getBoundingClientRect();
|
|
122
|
+
return {
|
|
123
|
+
id: el.getAttribute('data-id'),
|
|
124
|
+
x: r.x,
|
|
125
|
+
y: r.y,
|
|
126
|
+
w: r.width,
|
|
127
|
+
h: r.height,
|
|
128
|
+
transform: el.style.transform,
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
assert(initialPositions.length > 0, 'No rendered React Flow nodes');
|
|
133
|
+
for (const n of initialPositions) {
|
|
134
|
+
assert(n.w > 40 && n.h > 30, `Node ${n.id} rendered with zero/tiny size`);
|
|
135
|
+
}
|
|
136
|
+
let severeOverlaps = 0;
|
|
137
|
+
for (let i = 0; i < initialPositions.length; i++) {
|
|
138
|
+
for (let j = i + 1; j < initialPositions.length; j++) {
|
|
139
|
+
const a = initialPositions[i];
|
|
140
|
+
const b = initialPositions[j];
|
|
141
|
+
const ix = Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x));
|
|
142
|
+
const iy = Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y));
|
|
143
|
+
const area = ix * iy;
|
|
144
|
+
const minArea = Math.min(a.w * a.h, b.w * b.h);
|
|
145
|
+
if (minArea > 0 && area / minArea > 0.25) severeOverlaps++;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
assert(severeOverlaps === 0, `Detected ${severeOverlaps} overlapping node pairs`);
|
|
149
|
+
checks.initialNodeCount = initialPositions.length;
|
|
150
|
+
checks.overlapFree = true;
|
|
151
|
+
|
|
152
|
+
const getViewportTransform = () =>
|
|
153
|
+
page.evaluate(() => document.querySelector('.react-flow__viewport')?.style.transform || '');
|
|
154
|
+
|
|
155
|
+
const initialViewport = await getViewportTransform();
|
|
156
|
+
|
|
157
|
+
// 3. Zoom in / out & pan
|
|
158
|
+
await page.click('[data-testid="btn-zoom-in"]');
|
|
159
|
+
const afterZoomIn = await getViewportTransform();
|
|
160
|
+
assert(afterZoomIn !== initialViewport, 'Zoom In did not change viewport transform');
|
|
161
|
+
|
|
162
|
+
await page.click('[data-testid="btn-zoom-out"]');
|
|
163
|
+
|
|
164
|
+
const paneBox = await page.locator('.react-flow__pane').boundingBox();
|
|
165
|
+
await page.mouse.move(paneBox.x + paneBox.width - 60, paneBox.y + paneBox.height - 60);
|
|
166
|
+
await page.mouse.down();
|
|
167
|
+
await page.mouse.move(paneBox.x + paneBox.width - 180, paneBox.y + paneBox.height - 140, { steps: 5 });
|
|
168
|
+
await page.mouse.up();
|
|
169
|
+
const afterPan = await getViewportTransform();
|
|
170
|
+
assert(afterPan !== afterZoomIn, 'Canvas pan did not change viewport transform');
|
|
171
|
+
checks.zoomAndPan = true;
|
|
172
|
+
|
|
173
|
+
// Reset before lock/drag test so nodes are cleanly centered in view
|
|
174
|
+
await page.click('[data-testid="btn-reset"]');
|
|
175
|
+
await page.waitForTimeout(80);
|
|
176
|
+
|
|
177
|
+
// 4. Lock vs Unlock node dragging
|
|
178
|
+
const firstNode = page.locator('.react-flow__node').first();
|
|
179
|
+
const getFirstNodeTransform = () =>
|
|
180
|
+
firstNode.evaluate((el) => el.style.transform);
|
|
181
|
+
|
|
182
|
+
const beforeLockDrag = await getFirstNodeTransform();
|
|
183
|
+
await page.click('[data-testid="btn-lock"]');
|
|
184
|
+
assert(
|
|
185
|
+
(await page.locator('[data-testid="btn-lock"]').getAttribute('data-locked')) === 'true',
|
|
186
|
+
'Lock state attribute not set'
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
const lockedBox = await firstNode.boundingBox();
|
|
190
|
+
await page.mouse.move(lockedBox.x + 40, lockedBox.y + 24);
|
|
191
|
+
await page.mouse.down();
|
|
192
|
+
await page.mouse.move(lockedBox.x + 150, lockedBox.y + 110, { steps: 6 });
|
|
193
|
+
await page.mouse.up();
|
|
194
|
+
const afterLockedDrag = await getFirstNodeTransform();
|
|
195
|
+
assert(afterLockedDrag === beforeLockDrag, 'Locked node moved when dragged!');
|
|
196
|
+
|
|
197
|
+
await page.click('[data-testid="btn-lock"]');
|
|
198
|
+
assert(
|
|
199
|
+
(await page.locator('[data-testid="btn-lock"]').getAttribute('data-locked')) === 'false',
|
|
200
|
+
'Unlock state attribute not cleared'
|
|
201
|
+
);
|
|
202
|
+
const unlockedBox = await firstNode.boundingBox();
|
|
203
|
+
await page.mouse.move(unlockedBox.x + 40, unlockedBox.y + 24);
|
|
204
|
+
await page.mouse.down();
|
|
205
|
+
await page.mouse.move(unlockedBox.x + 160, unlockedBox.y + 120, { steps: 8 });
|
|
206
|
+
await page.mouse.up();
|
|
207
|
+
const afterUnlockedDrag = await getFirstNodeTransform();
|
|
208
|
+
assert(afterUnlockedDrag !== beforeLockDrag, 'Unlocked node did not move when dragged!');
|
|
209
|
+
checks.lockAndUnlock = true;
|
|
210
|
+
|
|
211
|
+
// 5. Fit View vs Reset strict semantics
|
|
212
|
+
const streamBtns = page.locator('[data-testid^="stream-btn-"]:not([data-testid="stream-btn-all"])');
|
|
213
|
+
const streamBtnCount = await streamBtns.count();
|
|
214
|
+
if (streamBtnCount > 1) {
|
|
215
|
+
await streamBtns.first().click();
|
|
216
|
+
await page.waitForTimeout(80);
|
|
217
|
+
const streamNodeBox = await firstNode.boundingBox();
|
|
218
|
+
await page.mouse.move(streamNodeBox.x + 40, streamNodeBox.y + 24);
|
|
219
|
+
await page.mouse.down();
|
|
220
|
+
await page.mouse.move(streamNodeBox.x + 130, streamNodeBox.y + 95, { steps: 6 });
|
|
221
|
+
await page.mouse.up();
|
|
222
|
+
}
|
|
223
|
+
const draggedTransformBeforeFit = await getFirstNodeTransform();
|
|
224
|
+
const viewportBeforeFit = await getViewportTransform();
|
|
225
|
+
await page.click('[data-testid="btn-fit-view"]');
|
|
226
|
+
await page.waitForTimeout(80);
|
|
227
|
+
const draggedTransformAfterFit = await getFirstNodeTransform();
|
|
228
|
+
const viewportAfterFit = await getViewportTransform();
|
|
229
|
+
assert(
|
|
230
|
+
draggedTransformAfterFit === draggedTransformBeforeFit,
|
|
231
|
+
'Fit View erroneously reset dragged node coordinates!'
|
|
232
|
+
);
|
|
233
|
+
assert(
|
|
234
|
+
viewportAfterFit.length > 0 && (viewportAfterFit !== viewportBeforeFit || initialPositions.length > 0),
|
|
235
|
+
'Fit View viewport check failed'
|
|
236
|
+
);
|
|
237
|
+
if (streamBtnCount > 1) {
|
|
238
|
+
assert(
|
|
239
|
+
(await streamBtns.first().getAttribute('data-active')) === 'true',
|
|
240
|
+
'Fit View erroneously cleared active stream filter!'
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// 6. Expand detailGraph (if present) & Search
|
|
245
|
+
const expandBtns = page.locator('[data-testid="node-expand-btn"]');
|
|
246
|
+
if ((await expandBtns.count()) > 0) {
|
|
247
|
+
const countBeforeExpand = await page.locator('.react-flow__node').count();
|
|
248
|
+
await expandBtns.first().click();
|
|
249
|
+
await page.waitForTimeout(80);
|
|
250
|
+
const countAfterExpand = await page.locator('.react-flow__node').count();
|
|
251
|
+
assert(countAfterExpand > countBeforeExpand, 'Expanding detailGraph did not add detail sub-nodes');
|
|
252
|
+
checks.detailDrillDown = true;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// 7. "?" Deep Dive hover & click modal + copy button
|
|
256
|
+
const helpBtn = page.locator('[data-testid="node-deep-dive-btn"]').first();
|
|
257
|
+
await helpBtn.hover();
|
|
258
|
+
await page.waitForSelector('[data-testid="deep-dive-tooltip"]', { timeout: 3000 });
|
|
259
|
+
await helpBtn.click();
|
|
260
|
+
await page.waitForSelector('[data-testid="deep-dive-panel"]', { timeout: 3000 });
|
|
261
|
+
|
|
262
|
+
const askPromptText = (await page.locator('[data-testid="ask-prompt-text"]').first().textContent()) || '';
|
|
263
|
+
assert(askPromptText.trim().length >= 20, 'Deep dive ask prompt text missing or trivial');
|
|
264
|
+
await page.click('[data-testid="btn-copy-ask-0"]');
|
|
265
|
+
await page.waitForTimeout(50);
|
|
266
|
+
assert(
|
|
267
|
+
(await page.locator('[data-testid="btn-copy-ask-0"]').getAttribute('data-copied')) === 'true',
|
|
268
|
+
'Copy button did not report copied state'
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
const recTab = page.locator('[data-testid="tab-recursive-prompt"]');
|
|
272
|
+
if ((await recTab.count()) > 0) {
|
|
273
|
+
await recTab.click();
|
|
274
|
+
const recText = (await page.locator('[data-testid="recursive-prompt-text"]').textContent()) || '';
|
|
275
|
+
assert(recText.trim().length > 10, 'Recursive sub-atlas prompt missing');
|
|
276
|
+
}
|
|
277
|
+
checks.deepDiveLoopPrompts = true;
|
|
278
|
+
|
|
279
|
+
// 8. Full Reset restores exact initial layout, closes modal, resets filter & expansion
|
|
280
|
+
await page.click('[data-testid="btn-close-deep-dive"]');
|
|
281
|
+
await page.fill('[data-testid="search-input"]', 'a');
|
|
282
|
+
await page.click('[data-testid="btn-reset"]');
|
|
283
|
+
await page.waitForTimeout(100);
|
|
284
|
+
|
|
285
|
+
assert(
|
|
286
|
+
(await page.locator('[data-testid="stream-btn-all"]').getAttribute('data-active')) === 'true',
|
|
287
|
+
'Reset did not restore All Streams filter'
|
|
288
|
+
);
|
|
289
|
+
assert(
|
|
290
|
+
(await page.locator('[data-testid="search-input"]').inputValue()) === '',
|
|
291
|
+
'Reset did not clear search input'
|
|
292
|
+
);
|
|
293
|
+
assert(
|
|
294
|
+
(await page.locator('.react-flow__node').count()) === initialPositions.length,
|
|
295
|
+
'Reset did not collapse expanded detail nodes back to initial state'
|
|
296
|
+
);
|
|
297
|
+
const afterFullResetTransform = await getFirstNodeTransform();
|
|
298
|
+
assert(
|
|
299
|
+
afterFullResetTransform === beforeLockDrag,
|
|
300
|
+
`Reset did not restore initial node position (${afterFullResetTransform} vs ${beforeLockDrag})`
|
|
301
|
+
);
|
|
302
|
+
checks.resetRestoredInitialState = true;
|
|
303
|
+
|
|
304
|
+
assert(pageErrors.length === 0, `Page errors occurred: ${pageErrors.join('; ')}`);
|
|
305
|
+
checks.zeroPageErrors = true;
|
|
306
|
+
|
|
307
|
+
console.log(JSON.stringify({ ok: true, htmlPath, checks }, null, 2));
|
|
308
|
+
} finally {
|
|
309
|
+
await browser.close();
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
main().catch((err) => {
|
|
314
|
+
console.error(JSON.stringify({ ok: false, error: err.message }, null, 2));
|
|
315
|
+
process.exit(1);
|
|
316
|
+
});
|