@@ -356,6 +356,20 @@ fn route(request: *std.http.Server.Request, arena: std.mem.Allocator, bridge: *B
356356 handleEvalHandle (request , arena , bridge );
357357 } else if (std .mem .eql (u8 , clean_path , "/diff/url" )) {
358358 handleDiffUrl (request , arena , bridge );
359+ } else if (std .mem .eql (u8 , clean_path , "/cache/set" )) {
360+ handleCacheSet (request , arena , bridge );
361+ } else if (std .mem .eql (u8 , clean_path , "/cache/get" )) {
362+ handleCacheGet (request , arena , bridge );
363+ } else if (std .mem .eql (u8 , clean_path , "/cache/clear" )) {
364+ handleCacheClear (request , arena , bridge );
365+ } else if (std .mem .eql (u8 , clean_path , "/cache/list" )) {
366+ handleCacheList (request , arena , bridge );
367+ } else if (std .mem .eql (u8 , clean_path , "/screenshot/som" )) {
368+ handleScreenshotSom (request , arena , bridge );
369+ } else if (std .mem .eql (u8 , clean_path , "/snapshot/changes" )) {
370+ handleSnapshotChanges (request , arena , bridge );
371+ } else if (std .mem .eql (u8 , clean_path , "/recording/export" )) {
372+ handleRecordingExport (request , arena , bridge );
359373 } else {
360374 resp .sendError (request , 404 , "Not Found" );
361375 }
@@ -936,7 +950,35 @@ fn handleSnapshot(request: *std.http.Server.Request, arena: std.mem.Allocator, b
936950 ref_cache .node_count = snapshot .len ;
937951 }
938952
939- sendSnapshotResponse (request , arena , snapshot , opts );
953+ // Hybrid snapshot: if include_screenshot=true, wrap snapshot text with a screenshot
954+ const include_screenshot = if (getQueryParam (target , "include_screenshot" )) | v | std .mem .eql (u8 , v , "true" ) else false ;
955+ if (include_screenshot ) {
956+ const a11y_mod = @import ("../snapshot/a11y.zig" );
957+ const snap_text = a11y_mod .formatCompact (snapshot , arena ) catch {
958+ sendSnapshotResponse (request , arena , snapshot , opts );
959+ return ;
960+ };
961+ const screenshot_response = client .send (arena , protocol .Methods .page_capture_screenshot , "{\" format\" :\" png\" ,\" quality\" :80}" ) catch {
962+ sendSnapshotResponse (request , arena , snapshot , opts );
963+ return ;
964+ };
965+ const screenshot_data = extractSimpleJsonString (screenshot_response , 0 , "\" data\" " ) orelse "" ;
966+ const escaped_snap = jsonEscapeAlloc (arena , snap_text ) orelse {
967+ sendSnapshotResponse (request , arena , snapshot , opts );
968+ return ;
969+ };
970+ const escaped_ss = jsonEscapeAlloc (arena , screenshot_data ) orelse {
971+ sendSnapshotResponse (request , arena , snapshot , opts );
972+ return ;
973+ };
974+ const body = std .fmt .allocPrint (arena , "{{\" snapshot\" :\" {s}\" ,\" screenshot\" :\" {s}\" }}" , .{ escaped_snap , escaped_ss }) catch {
975+ sendSnapshotResponse (request , arena , snapshot , opts );
976+ return ;
977+ };
978+ resp .sendJson (request , body );
979+ } else {
980+ sendSnapshotResponse (request , arena , snapshot , opts );
981+ }
940982}
941983
942984fn sendSnapshotResponse (request : * std.http.Server.Request , arena : std.mem.Allocator , snapshot : []const @import ("../snapshot/a11y.zig" ).A11yNode , opts : @import ("../snapshot/a11y.zig" ).SnapshotOpts ) void {
@@ -6756,6 +6798,200 @@ fn handleDiffUrl(request: *std.http.Server.Request, arena: std.mem.Allocator, br
67566798}
67576799
67586800
6801+ // --- Action Cache ---
6802+
6803+ fn handleCacheSet (request : * std.http.Server.Request , arena : std.mem.Allocator , bridge : * Bridge ) void {
6804+ const target = request .head .target ;
6805+ const tab_id = requireEffectiveTabId (arena , request , bridge ) orelse return ;
6806+ const key = getQueryParam (target , "key" ) orelse { resp .sendError (request , 400 , "Missing key parameter" ); return ; };
6807+ const ref = getQueryParam (target , "ref" ) orelse "" ;
6808+ const action = getQueryParam (target , "action" ) orelse "" ;
6809+ const client = bridge .getCdpClient (tab_id ) orelse { resp .sendError (request , 404 , "Tab not found" ); return ; };
6810+ rememberCurrentTab (request , bridge , tab_id );
6811+
6812+ const escaped_key = jsonEscapeAlloc (arena , key ) orelse { resp .sendError (request , 500 , "Internal Server Error" ); return ; };
6813+ const escaped_ref = jsonEscapeAlloc (arena , ref ) orelse { resp .sendError (request , 500 , "Internal Server Error" ); return ; };
6814+ const escaped_action = jsonEscapeAlloc (arena , action ) orelse { resp .sendError (request , 500 , "Internal Server Error" ); return ; };
6815+
6816+ const js = std .fmt .allocPrint (arena ,
6817+ \\(function() {{
6818+ \\ if (!window.__kuri_action_cache) window.__kuri_action_cache = {{}};
6819+ \\ window.__kuri_action_cache["{s}"] = {{ref:"{s}",action:"{s}",url:location.href,timestamp:Date.now()}};
6820+ \\ return JSON.stringify({{ok:true,key:"{s}"}});
6821+ \\}})()
6822+ , .{ escaped_key , escaped_ref , escaped_action , escaped_key }) catch { resp .sendError (request , 500 , "Internal Server Error" ); return ; };
6823+
6824+ const val = evalValueString (arena , client , js ) orelse { resp .sendError (request , 502 , "CDP command failed" ); return ; };
6825+ resp .sendJson (request , val );
6826+ }
6827+
6828+ fn handleCacheGet (request : * std.http.Server.Request , arena : std.mem.Allocator , bridge : * Bridge ) void {
6829+ const target = request .head .target ;
6830+ const tab_id = requireEffectiveTabId (arena , request , bridge ) orelse return ;
6831+ const key = getQueryParam (target , "key" ) orelse { resp .sendError (request , 400 , "Missing key parameter" ); return ; };
6832+ const client = bridge .getCdpClient (tab_id ) orelse { resp .sendError (request , 404 , "Tab not found" ); return ; };
6833+ rememberCurrentTab (request , bridge , tab_id );
6834+
6835+ const escaped_key = jsonEscapeAlloc (arena , key ) orelse { resp .sendError (request , 500 , "Internal Server Error" ); return ; };
6836+
6837+ const js = std .fmt .allocPrint (arena ,
6838+ \\(function() {{
6839+ \\ var c = (window.__kuri_action_cache || {{}})["{s}"];
6840+ \\ if (!c) return JSON.stringify({{found:false}});
6841+ \\ c.stale = (c.url !== location.href);
6842+ \\ return JSON.stringify(c);
6843+ \\}})()
6844+ , .{escaped_key }) catch { resp .sendError (request , 500 , "Internal Server Error" ); return ; };
6845+
6846+ const val = evalValueString (arena , client , js ) orelse { resp .sendError (request , 502 , "CDP command failed" ); return ; };
6847+ resp .sendJson (request , val );
6848+ }
6849+
6850+ fn handleCacheClear (request : * std.http.Server.Request , arena : std.mem.Allocator , bridge : * Bridge ) void {
6851+ const tab_id = requireEffectiveTabId (arena , request , bridge ) orelse return ;
6852+ const client = bridge .getCdpClient (tab_id ) orelse { resp .sendError (request , 404 , "Tab not found" ); return ; };
6853+ rememberCurrentTab (request , bridge , tab_id );
6854+
6855+ const js = "(function() { window.__kuri_action_cache = {}; return JSON.stringify({ok:true,action:\" cache_cleared\" }); })()" ;
6856+ const val = evalValueString (arena , client , js ) orelse { resp .sendError (request , 502 , "CDP command failed" ); return ; };
6857+ resp .sendJson (request , val );
6858+ }
6859+
6860+ fn handleCacheList (request : * std.http.Server.Request , arena : std.mem.Allocator , bridge : * Bridge ) void {
6861+ const tab_id = requireEffectiveTabId (arena , request , bridge ) orelse return ;
6862+ const client = bridge .getCdpClient (tab_id ) orelse { resp .sendError (request , 404 , "Tab not found" ); return ; };
6863+ rememberCurrentTab (request , bridge , tab_id );
6864+
6865+ const js = "(function() { return JSON.stringify({entries: window.__kuri_action_cache || {}, count: Object.keys(window.__kuri_action_cache || {}).length}); })()" ;
6866+ const val = evalValueString (arena , client , js ) orelse { resp .sendError (request , 502 , "CDP command failed" ); return ; };
6867+ resp .sendJson (request , val );
6868+ }
6869+
6870+ // --- Set-of-Marks Screenshot ---
6871+
6872+ fn handleScreenshotSom (request : * std.http.Server.Request , arena : std.mem.Allocator , bridge : * Bridge ) void {
6873+ const tab_id = requireEffectiveTabId (arena , request , bridge ) orelse return ;
6874+ const client = bridge .getCdpClient (tab_id ) orelse { resp .sendError (request , 404 , "Tab not found" ); return ; };
6875+ rememberCurrentTab (request , bridge , tab_id );
6876+
6877+ // Step 1: Inject SoM overlay and get element map
6878+ const inject_js =
6879+ \\(function() {
6880+ \\ var overlay = document.createElement('div');
6881+ \\ overlay.id = '__kuri_som';
6882+ \\ overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;pointer-events:none;z-index:999999';
6883+ \\ var elements = document.querySelectorAll('a,button,input,select,textarea,[role]');
6884+ \\ var idx = 0;
6885+ \\ var map = [];
6886+ \\ elements.forEach(function(el) {
6887+ \\ var r = el.getBoundingClientRect();
6888+ \\ if (r.width === 0 || r.height === 0) return;
6889+ \\ var s = getComputedStyle(el);
6890+ \\ if (s.display === 'none' || s.visibility === 'hidden') return;
6891+ \\ var box = document.createElement('div');
6892+ \\ box.style.cssText = 'position:fixed;left:'+r.x+'px;top:'+r.y+'px;width:'+r.width+'px;height:'+r.height+'px;border:2px solid red;background:rgba(255,0,0,0.1);pointer-events:none';
6893+ \\ var label = document.createElement('span');
6894+ \\ label.textContent = idx;
6895+ \\ label.style.cssText = 'position:absolute;top:-14px;left:0;background:red;color:white;font:bold 10px sans-serif;padding:1px 3px;border-radius:2px';
6896+ \\ box.appendChild(label);
6897+ \\ overlay.appendChild(box);
6898+ \\ map.push({idx:idx,tag:el.tagName.toLowerCase(),text:(el.textContent||'').trim().substring(0,40),x:Math.round(r.x),y:Math.round(r.y),w:Math.round(r.width),h:Math.round(r.height)});
6899+ \\ idx++;
6900+ \\ });
6901+ \\ document.body.appendChild(overlay);
6902+ \\ return JSON.stringify({count:idx,elements:map});
6903+ \\})()
6904+ ;
6905+ const elements_json = evalValueString (arena , client , inject_js ) orelse { resp .sendError (request , 502 , "Failed to inject SoM overlay" ); return ; };
6906+
6907+ // Step 2: Take screenshot
6908+ const screenshot_response = client .send (arena , protocol .Methods .page_capture_screenshot , "{\" format\" :\" png\" ,\" quality\" :80}" ) catch {
6909+ // Clean up overlay before returning error
6910+ _ = evalValueString (arena , client , "document.getElementById('__kuri_som')?.remove()" );
6911+ resp .sendError (request , 502 , "Screenshot capture failed" );
6912+ return ;
6913+ };
6914+ const screenshot_data = extractSimpleJsonString (screenshot_response , 0 , "\" data\" " ) orelse "" ;
6915+
6916+ // Step 3: Remove overlay
6917+ _ = evalValueString (arena , client , "document.getElementById('__kuri_som')?.remove()" );
6918+
6919+ // Step 4: Build combined response
6920+ const escaped_ss = jsonEscapeAlloc (arena , screenshot_data ) orelse { resp .sendError (request , 500 , "Internal Server Error" ); return ; };
6921+ const body = std .fmt .allocPrint (arena , "{{\" screenshot\" :\" {s}\" ,\" elements\" :{s}}}" , .{ escaped_ss , elements_json }) catch { resp .sendError (request , 500 , "Internal Server Error" ); return ; };
6922+ resp .sendJson (request , body );
6923+ }
6924+
6925+ // --- Smart Diff (Snapshot Changes) ---
6926+
6927+ fn handleSnapshotChanges (request : * std.http.Server.Request , arena : std.mem.Allocator , bridge : * Bridge ) void {
6928+ const tab_id = requireEffectiveTabId (arena , request , bridge ) orelse return ;
6929+ const client = bridge .getCdpClient (tab_id ) orelse { resp .sendError (request , 404 , "Tab not found" ); return ; };
6930+ rememberCurrentTab (request , bridge , tab_id );
6931+
6932+ // Step 1: Get previous snapshot text from JS-side storage
6933+ const prev_text = evalValueString (arena , client , "(function() { return window.__kuri_prev_snapshot || ''; })()" ) orelse "" ;
6934+
6935+ // Step 2: Take new snapshot via a11y tree
6936+ const raw_response = client .send (arena , protocol .Methods .accessibility_get_full_tree , null ) catch { resp .sendError (request , 502 , "CDP command failed" ); return ; };
6937+ const a11y = @import ("../snapshot/a11y.zig" );
6938+ const nodes = parseA11yNodes (arena , raw_response ) catch { resp .sendError (request , 500 , "Failed to parse a11y tree" ); return ; };
6939+ const snapshot = a11y .buildSnapshot (nodes , .{ .compact = true }, arena ) catch { resp .sendError (request , 500 , "Failed to build snapshot" ); return ; };
6940+ const new_text = a11y .formatCompact (snapshot , arena ) catch { resp .sendError (request , 500 , "Failed to format snapshot" ); return ; };
6941+
6942+ // Step 3: Store new snapshot as previous
6943+ const store_escaped = jsonEscapeAlloc (arena , new_text ) orelse { resp .sendError (request , 500 , "Internal Server Error" ); return ; };
6944+ const store_js = std .fmt .allocPrint (arena ,
6945+ \\(function() {{ window.__kuri_prev_snapshot = "{s}"; return "ok"; }})()
6946+ , .{store_escaped }) catch { resp .sendError (request , 500 , "Internal Server Error" ); return ; };
6947+ _ = evalValueString (arena , client , store_js );
6948+
6949+ // Step 4: Diff line by line using JS to avoid Zig ArrayList API issues
6950+ // We do a simple JS-based diff since both texts are available
6951+ const escaped_prev = jsonEscapeAlloc (arena , prev_text ) orelse { resp .sendError (request , 500 , "Internal Server Error" ); return ; };
6952+ const escaped_new = jsonEscapeAlloc (arena , new_text ) orelse { resp .sendError (request , 500 , "Internal Server Error" ); return ; };
6953+ const diff_js = std .fmt .allocPrint (arena ,
6954+ \\(function() {{
6955+ \\ var prev = "{s}".split("\n").filter(function(l) {{ return l.length > 0; }});
6956+ \\ var curr = "{s}".split("\n").filter(function(l) {{ return l.length > 0; }});
6957+ \\ var prevSet = new Set(prev);
6958+ \\ var currSet = new Set(curr);
6959+ \\ var added = curr.filter(function(l) {{ return !prevSet.has(l); }});
6960+ \\ var removed = prev.filter(function(l) {{ return !currSet.has(l); }});
6961+ \\ var unchanged = curr.filter(function(l) {{ return prevSet.has(l); }}).length;
6962+ \\ return JSON.stringify({{added:added,removed:removed,unchanged_count:unchanged}});
6963+ \\}})()
6964+ , .{ escaped_prev , escaped_new }) catch { resp .sendError (request , 500 , "Internal Server Error" ); return ; };
6965+ const diff_result = evalValueString (arena , client , diff_js ) orelse { resp .sendError (request , 502 , "Diff computation failed" ); return ; };
6966+ resp .sendJson (request , diff_result );
6967+ }
6968+
6969+ // --- Recording Export ---
6970+
6971+ fn handleRecordingExport (request : * std.http.Server.Request , arena : std.mem.Allocator , bridge : * Bridge ) void {
6972+ const tab_id = requireEffectiveTabId (arena , request , bridge ) orelse return ;
6973+ const client = bridge .getCdpClient (tab_id ) orelse { resp .sendError (request , 404 , "Tab not found" ); return ; };
6974+ rememberCurrentTab (request , bridge , tab_id );
6975+
6976+ // Read the recording data from JS
6977+ const js =
6978+ \\(function() {
6979+ \\ var rec = window.__kuri_recording || [];
6980+ \\ var commands = [];
6981+ \\ rec.forEach(function(e) {
6982+ \\ if (e.type === 'click') {
6983+ \\ commands.push({path:'/action',action:'click',ref:e.target});
6984+ \\ } else if (e.type === 'input' || e.type === 'change') {
6985+ \\ commands.push({path:'/action',action:'fill',ref:e.target,value:e.value||''});
6986+ \\ }
6987+ \\ });
6988+ \\ return JSON.stringify({format:'batch',commands:commands,count:commands.length});
6989+ \\})()
6990+ ;
6991+ const val = evalValueString (arena , client , js ) orelse { resp .sendError (request , 502 , "CDP command failed" ); return ; };
6992+ resp .sendJson (request , val );
6993+ }
6994+
67596995
67606996test "screenshot routes match" {
67616997 for ([_ ][]const u8 { "/screenshot/annotated" , "/screenshot/diff" , "/screencast/start" , "/screencast/stop" }) | p | {
@@ -7034,8 +7270,11 @@ test "total endpoint count" {
70347270 "/react/inspect" , "/react/renders" , "/react/suspense" , "/recording/start" ,
70357271 "/recording/stop" , "/request/detail" , "/wait/download" , "/initscript/remove" ,
70367272 "/evalhandle" , "/diff/url" ,
7273+ // Advanced features
7274+ "/cache/set" , "/cache/get" , "/cache/clear" , "/cache/list" ,
7275+ "/screenshot/som" , "/snapshot/changes" , "/recording/export" ,
70377276 };
7038- try std .testing .expectEqual (@as (usize , 135 ), routes .len );
7277+ try std .testing .expectEqual (@as (usize , 142 ), routes .len );
70397278}
70407279
70417280test "buildGetExpression title" {
0 commit comments