[{"data":1,"prerenderedAt":1429},["ShallowReactive",2],{"seite-\u002Fopen-source\u002Fki-testet-ohne-fenster\u002F":3},{"id":4,"title":5,"body":6,"description":1420,"extension":1421,"meta":1422,"navigation":1423,"path":1424,"robots":1425,"seo":1426,"stem":1427,"__hash__":1428},"seiten\u002Fopen-source\u002Fki-testet-ohne-fenster.md","Ein KI-Agent testet meinen Editor ohne Fenster",{"type":7,"value":8,"toc":1409},"minimark",[9,13,28,34,37,42,45,80,84,95,98,101,105,145,148,462,465,782,789,793,796,1103,1114,1117,1121,1132,1228,1231,1234,1242,1256,1263,1266,1270,1273,1284,1287,1303,1309,1313,1316,1337,1340,1344,1355,1358,1362,1365,1388,1405],[10,11,5],"h1",{"id":12},"ein-ki-agent-testet-meinen-editor-ohne-fenster",[14,15,16,17,21,22,27],"p",{},"Meinen Editor ",[18,19,20],"code",{},"zid"," schreibe ich mit Claude Code. Wie es\ndazu kam, steht im ",[23,24,26],"a",{"href":25},"\u002Fopen-source\u002Feditor-suche\u002F","Artikel über die Editor-Suche",".\nHier geht es um die Frage, die sich danach stellt: Wie prüft ein KI-Agent\neine Oberfläche, die er weder anklicken noch sehen kann?",[14,29,30,31,33],{},"Im Web ist das gelöst. Playwright öffnet einen Browser, klickt, liest das DOM\nund macht Screenshots. ",[18,32,20],{}," hat nichts davon. Er ist in Zig geschrieben,\nzeichnet über wgpu mit eigenem Text-Renderer und legt das Layout mit Clay an.\nEs gibt kein DOM, keine Entwicklerwerkzeuge und keinen Browser, der\nirgendetwas abnimmt.",[14,35,36],{},"Ohne Hilfe schreibt ein Agent dort Oberflächencode blind. Am Ende steht\n„sollte funktionieren\", und geprüft habe dann ich, von Hand, im Fenster.\nDas habe ich umgedreht: Die App bekommt eine Fernbedienung und Augen, und\nder Agent benutzt beide selbst.",[38,39,41],"h2",{"id":40},"drei-ebenen","Drei Ebenen",[14,43,44],{},"Getestet wird auf drei Ebenen, und jede fängt etwas anderes.",[46,47,48,68,74],"ul",{},[49,50,51,55,56,59,60,63,64,67],"li",{},[52,53,54],"strong",{},"Unit-Tests in Zig"," prüfen Logik, die keine Oberfläche braucht. Das\nBlättern in PDFs liegt in ",[18,57,58],{},"ui\u002Fpdf_nav.zig",", die Git-Historie in\n",[18,61,62],{},"git\u002Fgit_history.zig",", der Ordnerdialog in ",[18,65,66],{},"ui\u002Ffolder_ops.zig",". Diese Module\nkennen Clay nicht und laufen in Millisekunden.",[49,69,70,73],{},[52,71,72],{},"E2E-Skripte in Python"," starten die echte App ohne Fenster, bedienen sie\nüber JSON-RPC und prüfen den Zustand, den die App als JSON zurückgibt. Es\nsind 23 Skripte mit zusammen gut 4000 Zeilen.",[49,75,76,79],{},[52,77,78],{},"Screenshots"," schreibt die App auf Anfrage in eine Datei. Claude sieht\nsie sich selbst an.",[38,81,83],{"id":82},"ohne-fenster-aber-mit-derselben-schleife","Ohne Fenster, aber mit derselben Schleife",[14,85,86,87,90,91,94],{},"Der Aufruf ist ",[18,88,89],{},"zig build run -- --headless --ai=off",". Die App öffnet dann\nkein Fenster, rendert in einen Puffer von 1200 × 800 Pixeln und lauscht auf\nPort 9999. ",[18,92,93],{},"--ai=off"," schaltet den eingebauten Agenten mit seinem lokalen\nSprachmodell ab.",[14,96,97],{},"Wichtig ist, dass der Headless-Modus keine eigene Welt ist. Früher hatte er\neine eigene Schleife von 27 Zeilen, die nur Ergebnisse aus Hintergrundarbeit\nabholte. Tabwechsel, Klicks im Explorer, verzögertes Schliessen von Tabs und\ngepufferte Eingaben liefen dort nie. Fehler in genau diesen Pfaden liessen\nsich nur mit sichtbarem Fenster nachstellen, und dieses Fenster stört, wenn\nich nebenbei am selben Desktop arbeite.",[14,99,100],{},"Heute läuft headless dieselbe Frame-Schleife wie im Fenster. Übersprungen\nwird nur, was ein Fenster voraussetzt: Fensterereignisse, Mauszeiger und die\nAusgabe auf den Bildschirm. Statt auf Ereignisse zu warten, schläft die\nSchleife 16 Millisekunden. Was headless getestet ist, ist damit derselbe Code,\nden ich im Fenster benutze.",[38,102,104],{"id":103},"json-rpc-als-fernbedienung","JSON-RPC als Fernbedienung",[14,106,107,108,111,112,111,115,111,118,111,121,124,125,128,129,132,133,136,137,140,141,144],{},"Der RPC-Server kennt 48 Methoden. Ein Teil davon bedient die App wie ein\nMensch: ",[18,109,110],{},"click",", ",[18,113,114],{},"right_click",[18,116,117],{},"move_mouse",[18,119,120],{},"scroll",[18,122,123],{},"key_press",",\n",[18,126,127],{},"type_text",". Ein anderer Teil liest Zustand: ",[18,130,131],{},"ui_state"," liefert offene\nDialoge, Menüs, Tabs und Fokus, ",[18,134,135],{},"editor_state"," Zeilen, Cursor und\nSuchleiste, ",[18,138,139],{},"pdf_state"," die aktuelle Seite. Mit ",[18,142,143],{},"element_bounds"," holt ein\nTest die Position eines beliebigen Layout-Elements, statt Koordinaten zu\nraten.",[14,146,147],{},"Auf der Python-Seite genügt dafür eine Funktion:",[149,150,155],"pre",{"className":151,"code":152,"language":153,"meta":154,"style":154},"language-python shiki shiki-themes github-light github-dark","def rpc(method, params=None):\n    msg = json.dumps({\"jsonrpc\": \"2.0\", \"method\": method, \"params\": params or [], \"id\": 1}) + \"\\n\"\n    with socket.create_connection((HOST, PORT), timeout=10) as s:\n        s.sendall(msg.encode())\n        data = b\"\"\n        while not data.endswith(b\"\\n\"):\n            chunk = s.recv(65536)\n            if not chunk:\n                break\n            data += chunk\n    res = json.loads(data.decode())\n    if \"error\" in res:\n        raise RuntimeError(f\"{method}: {res['error']}\")\n    return res[\"result\"]\n","python","",[18,156,157,184,248,286,292,306,330,347,358,364,376,387,402,447],{"__ignoreMap":154},[158,159,162,166,170,174,177,181],"span",{"class":160,"line":161},"line",1,[158,163,165],{"class":164},"szBVR","def",[158,167,169],{"class":168},"sScJk"," rpc",[158,171,173],{"class":172},"sVt8B","(method, params",[158,175,176],{"class":164},"=",[158,178,180],{"class":179},"sj4cs","None",[158,182,183],{"class":172},"):\n",[158,185,187,190,192,195,199,202,205,207,210,213,216,219,222,225,228,230,233,236,239,242,245],{"class":160,"line":186},2,[158,188,189],{"class":172},"    msg ",[158,191,176],{"class":164},[158,193,194],{"class":172}," json.dumps({",[158,196,198],{"class":197},"sZZnC","\"jsonrpc\"",[158,200,201],{"class":172},": ",[158,203,204],{"class":197},"\"2.0\"",[158,206,111],{"class":172},[158,208,209],{"class":197},"\"method\"",[158,211,212],{"class":172},": method, ",[158,214,215],{"class":197},"\"params\"",[158,217,218],{"class":172},": params ",[158,220,221],{"class":164},"or",[158,223,224],{"class":172}," [], ",[158,226,227],{"class":197},"\"id\"",[158,229,201],{"class":172},[158,231,232],{"class":179},"1",[158,234,235],{"class":172},"}) ",[158,237,238],{"class":164},"+",[158,240,241],{"class":197}," \"",[158,243,244],{"class":179},"\\n",[158,246,247],{"class":197},"\"\n",[158,249,251,254,257,260,262,265,268,272,274,277,280,283],{"class":160,"line":250},3,[158,252,253],{"class":164},"    with",[158,255,256],{"class":172}," socket.create_connection((",[158,258,259],{"class":179},"HOST",[158,261,111],{"class":172},[158,263,264],{"class":179},"PORT",[158,266,267],{"class":172},"), ",[158,269,271],{"class":270},"s4XuR","timeout",[158,273,176],{"class":164},[158,275,276],{"class":179},"10",[158,278,279],{"class":172},") ",[158,281,282],{"class":164},"as",[158,284,285],{"class":172}," s:\n",[158,287,289],{"class":160,"line":288},4,[158,290,291],{"class":172},"        s.sendall(msg.encode())\n",[158,293,295,298,300,303],{"class":160,"line":294},5,[158,296,297],{"class":172},"        data ",[158,299,176],{"class":164},[158,301,302],{"class":164}," b",[158,304,305],{"class":197},"\"\"\n",[158,307,309,312,315,318,321,324,326,328],{"class":160,"line":308},6,[158,310,311],{"class":164},"        while",[158,313,314],{"class":164}," not",[158,316,317],{"class":172}," data.endswith(",[158,319,320],{"class":164},"b",[158,322,323],{"class":197},"\"",[158,325,244],{"class":179},[158,327,323],{"class":197},[158,329,183],{"class":172},[158,331,333,336,338,341,344],{"class":160,"line":332},7,[158,334,335],{"class":172},"            chunk ",[158,337,176],{"class":164},[158,339,340],{"class":172}," s.recv(",[158,342,343],{"class":179},"65536",[158,345,346],{"class":172},")\n",[158,348,350,353,355],{"class":160,"line":349},8,[158,351,352],{"class":164},"            if",[158,354,314],{"class":164},[158,356,357],{"class":172}," chunk:\n",[158,359,361],{"class":160,"line":360},9,[158,362,363],{"class":164},"                break\n",[158,365,367,370,373],{"class":160,"line":366},10,[158,368,369],{"class":172},"            data ",[158,371,372],{"class":164},"+=",[158,374,375],{"class":172}," chunk\n",[158,377,379,382,384],{"class":160,"line":378},11,[158,380,381],{"class":172},"    res ",[158,383,176],{"class":164},[158,385,386],{"class":172}," json.loads(data.decode())\n",[158,388,390,393,396,399],{"class":160,"line":389},12,[158,391,392],{"class":164},"    if",[158,394,395],{"class":197}," \"error\"",[158,397,398],{"class":164}," in",[158,400,401],{"class":172}," res:\n",[158,403,405,408,411,414,417,419,422,425,428,430,432,435,438,441,443,445],{"class":160,"line":404},13,[158,406,407],{"class":164},"        raise",[158,409,410],{"class":179}," RuntimeError",[158,412,413],{"class":172},"(",[158,415,416],{"class":164},"f",[158,418,323],{"class":197},[158,420,421],{"class":179},"{",[158,423,424],{"class":172},"method",[158,426,427],{"class":179},"}",[158,429,201],{"class":197},[158,431,421],{"class":179},[158,433,434],{"class":172},"res[",[158,436,437],{"class":197},"'error'",[158,439,440],{"class":172},"]",[158,442,427],{"class":179},[158,444,323],{"class":197},[158,446,346],{"class":172},[158,448,450,453,456,459],{"class":160,"line":449},14,[158,451,452],{"class":164},"    return",[158,454,455],{"class":172}," res[",[158,457,458],{"class":197},"\"result\"",[158,460,461],{"class":172},"]\n",[14,463,464],{},"Die eigentliche Arbeit steckt in der Frage, welcher Thread was darf. Der\nRPC-Server läuft in einem eigenen Thread, die Oberfläche im Hauptthread.\nÄndert ein RPC-Aufruf die Tab-Liste, während der Hauptthread sie gerade\nzeichnet, stürzt die App ab. Deshalb landen Eingaben in einer Warteschlange,\ndie der Hauptthread einmal pro Frame abarbeitet:",[149,466,470],{"className":467,"code":468,"language":469,"meta":154,"style":154},"language-zig shiki shiki-themes github-light github-dark","\u002F\u002F\u002F Vom Main-Thread pro Frame aufrufen: gepufferte Eingaben anwenden.\npub fn drainInputs(ctx: *E2EContext) void {\n    var batch: [64]InputEvent = undefined;\n    while (true) {\n        ctx.input_mutex.lock();\n        const n = @min(ctx.pending_inputs.items.len, batch.len);\n        @memcpy(batch[0..n], ctx.pending_inputs.items[0..n]);\n        ctx.pending_inputs.replaceRangeAssumeCapacity(0, n, &.{});\n        ctx.input_mutex.unlock();\n        if (n == 0) return;\n        for (batch[0..n]) |ev| applyInput(ctx.ui_system, ev);\n    }\n}\n","zig",[18,471,472,478,510,538,552,571,615,660,689,704,726,772,777],{"__ignoreMap":154},[158,473,474],{"class":160,"line":161},[158,475,477],{"class":476},"sJ8bj","\u002F\u002F\u002F Vom Main-Thread pro Frame aufrufen: gepufferte Eingaben anwenden.\n",[158,479,480,483,486,489,491,494,496,499,502,504,507],{"class":160,"line":186},[158,481,482],{"class":164},"pub",[158,484,485],{"class":164}," fn",[158,487,488],{"class":168}," drainInputs",[158,490,413],{"class":172},[158,492,493],{"class":270},"ctx",[158,495,201],{"class":172},[158,497,498],{"class":164},"*",[158,500,501],{"class":270},"E2EContext",[158,503,279],{"class":172},[158,505,506],{"class":164},"void",[158,508,509],{"class":172}," {\n",[158,511,512,515,518,521,524,526,529,532,535],{"class":160,"line":250},[158,513,514],{"class":164},"    var",[158,516,517],{"class":270}," batch",[158,519,520],{"class":172},": [",[158,522,523],{"class":179},"64",[158,525,440],{"class":172},[158,527,528],{"class":270},"InputEvent",[158,530,531],{"class":164}," =",[158,533,534],{"class":164}," undefined",[158,536,537],{"class":172},";\n",[158,539,540,543,546,549],{"class":160,"line":288},[158,541,542],{"class":164},"    while",[158,544,545],{"class":172}," (",[158,547,548],{"class":164},"true",[158,550,551],{"class":172},") {\n",[158,553,554,557,560,563,565,568],{"class":160,"line":294},[158,555,556],{"class":270},"        ctx",[158,558,559],{"class":172},".",[158,561,562],{"class":270},"input_mutex",[158,564,559],{"class":172},[158,566,567],{"class":168},"lock",[158,569,570],{"class":172},"();\n",[158,572,573,576,579,581,584,586,588,590,593,595,598,600,603,605,608,610,612],{"class":160,"line":308},[158,574,575],{"class":164},"        const",[158,577,578],{"class":270}," n",[158,580,531],{"class":164},[158,582,583],{"class":179}," @min",[158,585,413],{"class":172},[158,587,493],{"class":270},[158,589,559],{"class":172},[158,591,592],{"class":270},"pending_inputs",[158,594,559],{"class":172},[158,596,597],{"class":270},"items",[158,599,559],{"class":172},[158,601,602],{"class":270},"len",[158,604,111],{"class":172},[158,606,607],{"class":270},"batch",[158,609,559],{"class":172},[158,611,602],{"class":270},[158,613,614],{"class":172},");\n",[158,616,617,620,622,624,627,630,633,636,639,641,643,645,647,649,651,653,655,657],{"class":160,"line":332},[158,618,619],{"class":179},"        @memcpy",[158,621,413],{"class":172},[158,623,607],{"class":270},[158,625,626],{"class":172},"[",[158,628,629],{"class":179},"0",[158,631,632],{"class":172},"..",[158,634,635],{"class":270},"n",[158,637,638],{"class":172},"], ",[158,640,493],{"class":270},[158,642,559],{"class":172},[158,644,592],{"class":270},[158,646,559],{"class":172},[158,648,597],{"class":270},[158,650,626],{"class":172},[158,652,629],{"class":179},[158,654,632],{"class":172},[158,656,635],{"class":270},[158,658,659],{"class":172},"]);\n",[158,661,662,664,666,668,670,673,675,677,679,681,683,686],{"class":160,"line":349},[158,663,556],{"class":270},[158,665,559],{"class":172},[158,667,592],{"class":270},[158,669,559],{"class":172},[158,671,672],{"class":168},"replaceRangeAssumeCapacity",[158,674,413],{"class":172},[158,676,629],{"class":179},[158,678,111],{"class":172},[158,680,635],{"class":270},[158,682,111],{"class":172},[158,684,685],{"class":164},"&",[158,687,688],{"class":172},".{});\n",[158,690,691,693,695,697,699,702],{"class":160,"line":360},[158,692,556],{"class":270},[158,694,559],{"class":172},[158,696,562],{"class":270},[158,698,559],{"class":172},[158,700,701],{"class":168},"unlock",[158,703,570],{"class":172},[158,705,706,709,711,713,716,719,721,724],{"class":160,"line":366},[158,707,708],{"class":164},"        if",[158,710,545],{"class":172},[158,712,635],{"class":270},[158,714,715],{"class":164}," ==",[158,717,718],{"class":179}," 0",[158,720,279],{"class":172},[158,722,723],{"class":164},"return",[158,725,537],{"class":172},[158,727,728,731,733,735,737,739,741,743,746,749,752,754,757,759,761,763,766,768,770],{"class":160,"line":378},[158,729,730],{"class":164},"        for",[158,732,545],{"class":172},[158,734,607],{"class":270},[158,736,626],{"class":172},[158,738,629],{"class":179},[158,740,632],{"class":172},[158,742,635],{"class":270},[158,744,745],{"class":172},"]) ",[158,747,748],{"class":164},"|",[158,750,751],{"class":270},"ev",[158,753,748],{"class":164},[158,755,756],{"class":168}," applyInput",[158,758,413],{"class":172},[158,760,493],{"class":270},[158,762,559],{"class":172},[158,764,765],{"class":270},"ui_system",[158,767,111],{"class":172},[158,769,751],{"class":270},[158,771,614],{"class":172},[158,773,774],{"class":160,"line":389},[158,775,776],{"class":172},"    }\n",[158,778,779],{"class":160,"line":404},[158,780,781],{"class":172},"}\n",[14,783,784,785,788],{},"Lesende Aufrufe laufen weiterhin im Server-Thread. Gemeinsame Daten brauchen\ndann eine Sperre. Der Git-Status im Explorer hatte keine: Der Hauptthread\nersetzte die Map, während ein Test sie las, und die App stürzte in\n",[18,786,787],{},"isIgnored"," ab. Seither geht jeder Zugriff über drei Funktionen, die die\nSperre halten.",[38,790,792],{"id":791},"ein-test-liest-sich-wie-eine-bedienungsanleitung","Ein Test liest sich wie eine Bedienungsanleitung",[14,794,795],{},"So sieht der Anfang des Tests für das Blättern in PDFs aus:",[149,797,799],{"className":151,"code":798,"language":153,"meta":154,"style":154},"cfg = os.path.join(ROOT, \"tmp\", \"e2e_pdf_cfg\")\nshutil.rmtree(cfg, ignore_errors=True)\nenv = dict(os.environ, XDG_CONFIG_HOME=cfg)\nwait_port_free()\nproc = subprocess.Popen(\n    [\"zig\", \"build\", \"run\", \"--\", \"--headless\", \"--ai=off\", PDF],\n    cwd=ROOT, stdout=log, stderr=subprocess.STDOUT, env=env,\n    start_new_session=True,  # eigene Prozessgruppe, siehe finally\n)\ntry:\n    wait_port(proc)\n    settle(20)\n    # ...\n    check(st[\"pdf\"], f\"PDF-Tab ist aktiv (nach {time.time() - t0:.1f}s)\")\n    check(st[\"pages\"] >= 3, f\"Dokument hat {st['pages']} Seiten\")\n    to_first_page()\n    expect_page(0, \"Bild auf hält am Anfang bei Seite 1\")\n",[18,800,801,826,841,862,867,877,918,958,973,977,985,990,1000,1005,1041,1082,1088],{"__ignoreMap":154},[158,802,803,806,808,811,814,816,819,821,824],{"class":160,"line":161},[158,804,805],{"class":172},"cfg ",[158,807,176],{"class":164},[158,809,810],{"class":172}," os.path.join(",[158,812,813],{"class":179},"ROOT",[158,815,111],{"class":172},[158,817,818],{"class":197},"\"tmp\"",[158,820,111],{"class":172},[158,822,823],{"class":197},"\"e2e_pdf_cfg\"",[158,825,346],{"class":172},[158,827,828,831,834,836,839],{"class":160,"line":186},[158,829,830],{"class":172},"shutil.rmtree(cfg, ",[158,832,833],{"class":270},"ignore_errors",[158,835,176],{"class":164},[158,837,838],{"class":179},"True",[158,840,346],{"class":172},[158,842,843,846,848,851,854,857,859],{"class":160,"line":250},[158,844,845],{"class":172},"env ",[158,847,176],{"class":164},[158,849,850],{"class":179}," dict",[158,852,853],{"class":172},"(os.environ, ",[158,855,856],{"class":270},"XDG_CONFIG_HOME",[158,858,176],{"class":164},[158,860,861],{"class":172},"cfg)\n",[158,863,864],{"class":160,"line":288},[158,865,866],{"class":172},"wait_port_free()\n",[158,868,869,872,874],{"class":160,"line":294},[158,870,871],{"class":172},"proc ",[158,873,176],{"class":164},[158,875,876],{"class":172}," subprocess.Popen(\n",[158,878,879,882,885,887,890,892,895,897,900,902,905,907,910,912,915],{"class":160,"line":308},[158,880,881],{"class":172},"    [",[158,883,884],{"class":197},"\"zig\"",[158,886,111],{"class":172},[158,888,889],{"class":197},"\"build\"",[158,891,111],{"class":172},[158,893,894],{"class":197},"\"run\"",[158,896,111],{"class":172},[158,898,899],{"class":197},"\"--\"",[158,901,111],{"class":172},[158,903,904],{"class":197},"\"--headless\"",[158,906,111],{"class":172},[158,908,909],{"class":197},"\"--ai=off\"",[158,911,111],{"class":172},[158,913,914],{"class":179},"PDF",[158,916,917],{"class":172},"],\n",[158,919,920,923,925,927,929,932,934,937,940,942,945,948,950,953,955],{"class":160,"line":332},[158,921,922],{"class":270},"    cwd",[158,924,176],{"class":164},[158,926,813],{"class":179},[158,928,111],{"class":172},[158,930,931],{"class":270},"stdout",[158,933,176],{"class":164},[158,935,936],{"class":172},"log, ",[158,938,939],{"class":270},"stderr",[158,941,176],{"class":164},[158,943,944],{"class":172},"subprocess.",[158,946,947],{"class":179},"STDOUT",[158,949,111],{"class":172},[158,951,952],{"class":270},"env",[158,954,176],{"class":164},[158,956,957],{"class":172},"env,\n",[158,959,960,963,965,967,970],{"class":160,"line":349},[158,961,962],{"class":270},"    start_new_session",[158,964,176],{"class":164},[158,966,838],{"class":179},[158,968,969],{"class":172},",  ",[158,971,972],{"class":476},"# eigene Prozessgruppe, siehe finally\n",[158,974,975],{"class":160,"line":360},[158,976,346],{"class":172},[158,978,979,982],{"class":160,"line":366},[158,980,981],{"class":164},"try",[158,983,984],{"class":172},":\n",[158,986,987],{"class":160,"line":378},[158,988,989],{"class":172},"    wait_port(proc)\n",[158,991,992,995,998],{"class":160,"line":389},[158,993,994],{"class":172},"    settle(",[158,996,997],{"class":179},"20",[158,999,346],{"class":172},[158,1001,1002],{"class":160,"line":404},[158,1003,1004],{"class":476},"    # ...\n",[158,1006,1007,1010,1013,1015,1017,1020,1022,1025,1028,1031,1034,1036,1039],{"class":160,"line":449},[158,1008,1009],{"class":172},"    check(st[",[158,1011,1012],{"class":197},"\"pdf\"",[158,1014,638],{"class":172},[158,1016,416],{"class":164},[158,1018,1019],{"class":197},"\"PDF-Tab ist aktiv (nach ",[158,1021,421],{"class":179},[158,1023,1024],{"class":172},"time.time() ",[158,1026,1027],{"class":164},"-",[158,1029,1030],{"class":172}," t0",[158,1032,1033],{"class":164},":.1f",[158,1035,427],{"class":179},[158,1037,1038],{"class":197},"s)\"",[158,1040,346],{"class":172},[158,1042,1044,1046,1049,1052,1055,1058,1060,1062,1065,1067,1070,1073,1075,1077,1080],{"class":160,"line":1043},15,[158,1045,1009],{"class":172},[158,1047,1048],{"class":197},"\"pages\"",[158,1050,1051],{"class":172},"] ",[158,1053,1054],{"class":164},">=",[158,1056,1057],{"class":179}," 3",[158,1059,111],{"class":172},[158,1061,416],{"class":164},[158,1063,1064],{"class":197},"\"Dokument hat ",[158,1066,421],{"class":179},[158,1068,1069],{"class":172},"st[",[158,1071,1072],{"class":197},"'pages'",[158,1074,440],{"class":172},[158,1076,427],{"class":179},[158,1078,1079],{"class":197}," Seiten\"",[158,1081,346],{"class":172},[158,1083,1085],{"class":160,"line":1084},16,[158,1086,1087],{"class":172},"    to_first_page()\n",[158,1089,1091,1094,1096,1098,1101],{"class":160,"line":1090},17,[158,1092,1093],{"class":172},"    expect_page(",[158,1095,629],{"class":179},[158,1097,111],{"class":172},[158,1099,1100],{"class":197},"\"Bild auf hält am Anfang bei Seite 1\"",[158,1102,346],{"class":172},[14,1104,1105,1106,1109,1110,1113],{},"Jede Prüfung druckt eine Zeile mit ",[18,1107,1108],{},"PASS"," oder ",[18,1111,1112],{},"FAIL"," und einem Satz, der\nsagt, was gemeint ist. Das ist für den Agenten geschrieben: Er liest die\nAusgabe und weiss ohne Stacktrace, welcher Schritt gescheitert ist.",[14,1115,1116],{},"Zwei Details sind aus Schaden entstanden. Jeder Lauf bekommt ein frisches\nKonfigurationsverzeichnis, sonst stellt die App die Tabs der letzten Sitzung\nwieder her, und der Test misst fremden Zustand. Und der Test startet die App\nin einer eigenen Prozessgruppe, damit er am Ende wirklich alles beendet.",[38,1118,1120],{"id":1119},"was-der-screenshot-zeigt-und-der-zustand-nicht","Was der Screenshot zeigt und der Zustand nicht",[14,1122,1123,1124,1127,1128,1131],{},"Die RPC-Methode ",[18,1125,1126],{},"screenshot"," schreibt ein PPM nach ",[18,1129,1130],{},"tmp\u002F",". Claude liest keine\nPPM-Dateien, also wandelt der Agent sie vor dem Ansehen um:",[149,1133,1135],{"className":151,"code":1134,"language":153,"meta":154,"style":154},"from PIL import Image\nfor n in ['repo', 'diff', 'file', 'empty', 'split']:\n    Image.open(f'tmp\u002Fe2e_git_history_{n}.ppm').save(f'tmp\u002Fe2e_git_history_{n}.png')\n",[18,1136,1137,1151,1191],{"__ignoreMap":154},[158,1138,1139,1142,1145,1148],{"class":160,"line":161},[158,1140,1141],{"class":164},"from",[158,1143,1144],{"class":179}," PIL",[158,1146,1147],{"class":164}," import",[158,1149,1150],{"class":172}," Image\n",[158,1152,1153,1156,1159,1162,1165,1168,1170,1173,1175,1178,1180,1183,1185,1188],{"class":160,"line":186},[158,1154,1155],{"class":164},"for",[158,1157,1158],{"class":172}," n ",[158,1160,1161],{"class":164},"in",[158,1163,1164],{"class":172}," [",[158,1166,1167],{"class":197},"'repo'",[158,1169,111],{"class":172},[158,1171,1172],{"class":197},"'diff'",[158,1174,111],{"class":172},[158,1176,1177],{"class":197},"'file'",[158,1179,111],{"class":172},[158,1181,1182],{"class":197},"'empty'",[158,1184,111],{"class":172},[158,1186,1187],{"class":197},"'split'",[158,1189,1190],{"class":172},"]:\n",[158,1192,1193,1196,1198,1201,1203,1205,1207,1210,1213,1215,1217,1219,1221,1223,1226],{"class":160,"line":250},[158,1194,1195],{"class":172},"    Image.open(",[158,1197,416],{"class":164},[158,1199,1200],{"class":197},"'tmp\u002Fe2e_git_history_",[158,1202,421],{"class":179},[158,1204,635],{"class":172},[158,1206,427],{"class":179},[158,1208,1209],{"class":197},".ppm'",[158,1211,1212],{"class":172},").save(",[158,1214,416],{"class":164},[158,1216,1200],{"class":197},[158,1218,421],{"class":179},[158,1220,635],{"class":172},[158,1222,427],{"class":179},[158,1224,1225],{"class":197},".png'",[158,1227,346],{"class":172},[14,1229,1230],{},"Danach öffnet er die PNGs und beschreibt, was er sieht. Eine eigene\nBewertungsschicht gibt es nicht. Das Modell, das den Code geschrieben hat,\nsieht sich das Ergebnis an.",[14,1232,1233],{},"Manches gibt es nur im Bild. Beim Bau des Ordnerdialogs fehlten auf jedem\nScreenshot nach dem ersten die neuen Icons. Kein Zustandsfeld kennt Icons,\nder Fehler lag im Budget des Icon-Atlas, das headless nie zurückgesetzt\nwurde.",[14,1235,1236,1237,1241],{},"Anderes fehlt im Zustand nur, weil noch niemand danach gefragt hat. Ein\nScreenshot aus dem Explorer zeigte zwei Fehler auf einmal. Ein Klick auf\neine Datei lief über einen alten Codepfad, der den Text in den Buffer des\n",[1238,1239,1240],"em",{},"vorherigen"," Tabs schrieb; die vorherige Datei galt danach als geändert und\nzeigte fremden Inhalt. Und nach dem Schliessen des aktiven Tabs zeigte der\nEditor weiter den Inhalt des geschlossenen Tabs, aber unter dem Namen des\nNachbarn.",[14,1243,1244,1245,1248,1249,1252,1253,559],{},"Der JSON-Zustand konnte beides nicht verraten, denn er meldete nur, welcher\nTab aktiv ist, nicht welchen Buffer der Editor gerade anzeigt. Mit dem Fix\nkam deshalb eine Erweiterung: ",[18,1246,1247],{},"get_active_tab"," liefert seither auch\n",[18,1250,1251],{},"editor_file"," und ",[18,1254,1255],{},"editor_modified",[14,1257,1258,1259,1262],{},"Daraus ist ein Muster geworden. Der Screenshot findet den Fehler, ein neues\nZustandsfeld hält ihn fest. Beim nächsten Mal scheitert ein ",[18,1260,1261],{},"check",", und\nniemand muss mehr hinsehen.",[14,1264,1265],{},"Wo es auf das Aussehen selbst ankommt, prüft der Test die Pixel. Die\nSchaltflächen unter der PDF-Seite hellen beim Überfahren auf. Das Skript\nmacht einen Screenshot mit der Maus daneben und einen mit der Maus darauf\nund vergleicht die Farbe an derselben Stelle. Eine Bildbibliothek braucht\nes dafür nicht: PPM ist ein kurzer Textkopf und danach rohe RGB-Bytes.",[38,1267,1269],{"id":1268},"wenn-der-test-selbst-lügt","Wenn der Test selbst lügt",[14,1271,1272],{},"Nicht jeder rote Test meint die App.",[14,1274,1275,1276,1279,1280,1283],{},"Beim PDF-Test sprangen die Seitenzahlen zwischen zwei Aufrufen, ohne dass\ngeblättert wurde. Die Ursache lag im RPC-Server. Er band den Port mit\n",[18,1277,1278],{},"reuse_address",", und das setzt unter Linux auch ",[18,1281,1282],{},"SO_REUSEPORT",". Verwaiste\nInstanzen aus früheren Läufen lauschten also weiter, der Kernel verteilte\ndie Verbindungen, und ein Teil der Antworten kam aus einem alten Prozess mit\naltem Zustand. Heute bindet die App den Port exklusiv, und ein zweiter Start\nmeldet, dass er belegt ist.",[14,1285,1286],{},"Andere Fallen sind kleiner, aber stehen alle in der Projektdokumentation,\ndamit der Agent sie nicht neu entdecken muss:",[46,1288,1289,1297,1300],{},[49,1290,1291,1292,1294,1295,559],{},"Clay behält die Bounding-Box eines Elements, das nicht mehr gezeichnet\nwird. Ob ein Dialog offen ist, sagt deshalb ",[18,1293,131],{},", nicht\n",[18,1296,143],{},[49,1298,1299],{},"Der Icon-Atlas rasterisiert höchstens vier neue Icons pro Durchgang. Die\nHilfsfunktion für Screenshots rendert deshalb zweimal.",[49,1301,1302],{},"Ein Seitenwechsel erscheint erst im nächsten Frame. Tests fragen den\nZustand in einer kurzen Schleife ab, statt einmal zu warten und zu hoffen.",[14,1304,1305,1306,1308],{},"Manchmal findet der Test auch einen echten Fehler, den er selbst auslöst.\nDas Schreiben eines einzigen Screenshots in ",[18,1307,1130],{}," erzeugte rund 2000\nDateiereignisse. Jedes stiess eine eigene Aktualisierung des Git-Status an,\ndie Warteschlange lief voll, und jede andere Aufgabe konnte verworfen\nwerden, auch die Antworten des Chats. Seither meldet der Watcher gleiche\nEreignisse nur einmal, und die App aktualisiert den Git-Status höchstens\neinmal pro 300 Millisekunden. Beim selben Screenshot bleiben von 2037\nEreignissen zwei übrig und eine Aktualisierung.",[38,1310,1312],{"id":1311},"der-ablauf","Der Ablauf",[14,1314,1315],{},"In meinen Anweisungen an den Agenten steht testgetriebene Entwicklung als\nStandard: zuerst ein Test, der scheitert, dann der kleinste Code, der ihn\nbestehen lässt, dann aufräumen. An einer Oberfläche heisst das praktisch:",[1317,1318,1319,1322,1325,1334],"ol",{},[49,1320,1321],{},"Die Logik hinter der Funktion wandert in ein Modul ohne Clay, mit\nUnit-Tests.",[49,1323,1324],{},"Für den Weg durch die Oberfläche entsteht ein E2E-Skript oder ein neuer\nSchritt in einem bestehenden. Fehlt dem Skript ein Zustand, den es prüfen\nmüsste, bekommt die App zuerst die RPC-Methode dafür.",[49,1326,1327,1328,1330,1331,1333],{},"Der Agent startet das Skript, liest die ",[18,1329,1112],{},"-Zeilen, ändert den Code\nund startet neu, bis alles ",[18,1332,1108],{}," meldet.",[49,1335,1336],{},"Er sieht sich die Screenshots an. Fällt dort etwas auf, geht es zurück zu\nSchritt 2.",[14,1338,1339],{},"Die Commits enthalten Test und Code zusammen, die Reihenfolge ist in der\nHistorie daher nicht sichtbar. Sichtbar ist dagegen, warum die zweite Ebene\nnötig ist. Das Blättern in PDFs kam mit einem sauberen, unit-getesteten\nModul. In der App wirkte es trotzdem sprunghaft. Der Fix brachte das\nE2E-Skript mit und drei Ursachen, die kein Unit-Test sehen konnte: Der Server-Thread las\nZustand, der ihm nicht gehörte, das Mausrad zählte verkehrt herum, und\nClays Hover-Erkennung meldete in der PDF-Ansicht gar nichts.",[38,1341,1343],{"id":1342},"was-nicht-automatisch-läuft","Was nicht automatisch läuft",[14,1345,1346,1347,1350,1351,1354],{},"Kein Hook startet die Tests nach einer Änderung, und es gibt keine CI.\nWelche Skripte laufen, entscheidet der Agent anhand der Änderung. Dafür\nsteht in ",[18,1348,1349],{},"AGENTS.md"," bei jeder Funktion, welches Skript sie abdeckt. Eine\nRegel ist verbindlich formuliert: Nach jeder Änderung am Agenten-Code muss\n",[18,1352,1353],{},"scripts\u002Fe2e_ai_tools.py"," grün bleiben. Auch das ist eine Anweisung, keine\nAutomatik.",[14,1356,1357],{},"Ein Gesamtlauf aller 23 Skripte ist kein fester Schritt. Das ist die grösste\noffene Lücke: Eine Änderung am Explorer, die nebenbei den Terminal-Tab\nbricht, fällt erst auf, wenn jemand das passende Skript startet.",[38,1359,1361],{"id":1360},"was-man-für-die-eigene-app-braucht","Was man für die eigene App braucht",[14,1363,1364],{},"Nichts davon hängt an Zig. Wer eine native Desktop-App mit einem Agenten\nentwickeln will, braucht ungefähr das:",[46,1366,1367,1370,1373,1376,1379,1382,1385],{},[49,1368,1369],{},"Einen Modus ohne Fenster, der dieselbe Schleife durchläuft wie mit Fenster.",[49,1371,1372],{},"Eine lokale Schnittstelle für Eingaben, die im Hauptthread ankommen, nicht\nim Thread des Servers.",[49,1374,1375],{},"Lesende Aufrufe, die den Zustand der Oberfläche als JSON liefern, und\neinen, der die Position eines Elements verrät.",[49,1377,1378],{},"Screenshots in eine Datei, in einem Format, das das Modell lesen kann.",[49,1380,1381],{},"Frischen Zustand pro Testlauf und einen Port, den nur eine Instanz halten\nkann.",[49,1383,1384],{},"Ein Modell, das Bilder versteht.",[49,1386,1387],{},"Eine Datei, in der steht, welches Skript welche Funktion abdeckt und\nwelche Falle schon bekannt ist.",[14,1389,1390,1391,1397,1398,1401,1402,559],{},"Der Quelltext liegt offen: ",[23,1392,1396],{"href":1393,"rel":1394},"https:\u002F\u002Fgithub.com\u002Fgstrainovic\u002Fzid",[1395],"nofollow","github.com\u002Fgstrainovic\u002Fzid",".\nDie RPC-Methoden stehen in ",[18,1399,1400],{},"src\u002Fe2e_server.zig",", die Skripte unter\n",[18,1403,1404],{},"scripts\u002Fe2e_*.py",[1406,1407,1408],"style",{},"html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}",{"title":154,"searchDepth":186,"depth":186,"links":1410},[1411,1412,1413,1414,1415,1416,1417,1418,1419],{"id":40,"depth":186,"text":41},{"id":82,"depth":186,"text":83},{"id":103,"depth":186,"text":104},{"id":791,"depth":186,"text":792},{"id":1119,"depth":186,"text":1120},{"id":1268,"depth":186,"text":1269},{"id":1311,"depth":186,"text":1312},{"id":1342,"depth":186,"text":1343},{"id":1360,"depth":186,"text":1361},"Testgetriebene Entwicklung mit einem KI-Agenten an einer nativen Desktop-App. Headless-Modus, JSON-RPC als Fernbedienung und Screenshots, die der Agent selbst ansieht.","md",{},true,"\u002Fopen-source\u002Fki-testet-ohne-fenster",null,{"title":5,"description":1420},"open-source\u002Fki-testet-ohne-fenster","0a35ABzUM_e_v--Dv5kSxt99i1MSONhBB8GXO4cb54o",1789513497277]