@@ -2,13 +2,10 @@ import io
6import struct
5import struct
9import unittest
6import unittest
11−from support import HOST_DIR, HOST_SCRIPT, ITEMS3, HostCase, frame, host, unframe
8+from support import HOST_DIR, ITEMS3, HostCase, frame, host, unframe
14class Framing(HostCase):
11class Framing(HostCase):
@@ -55,17 +52,6 @@ class Origin(HostCase):
55 code, reply, _ = self.run_host({"v": 1, "id": "p", "op": "ping", "payload": {}}, origin=host.ALLOWED_ORIGIN.rstrip("/"))
52 code, reply, _ = self.run_host({"v": 1, "id": "p", "op": "ping", "payload": {}}, origin=host.ALLOWED_ORIGIN.rstrip("/"))
56 self.assertTrue(reply["ok"])
53 self.assertTrue(reply["ok"])
58− def test_store_origin_is_accepted_once_set(self):
59− store = "chrome-extension://" + "b" * 32 + "/"
60− self.assertFalse(host.origin_ok(store))
61− saved, host.STORE_ORIGIN = host.STORE_ORIGIN, store
63− self.assertTrue(host.origin_ok(store))
64− self.assertTrue(host.origin_ok(host.ALLOWED_ORIGIN))
65− self.assertEqual(host.manifest_for("/x/diagonal-host")["allowed_origins"], [host.ALLOWED_ORIGIN, store])
67− host.STORE_ORIGIN = saved
69 def test_origin_is_pinned(self):
55 def test_origin_is_pinned(self):
70 self.assertRegex(host.ALLOWED_ORIGIN, r"^chrome-extension://[a-p]{32}/$")
56 self.assertRegex(host.ALLOWED_ORIGIN, r"^chrome-extension://[a-p]{32}/$")
@@ -77,7 +63,7 @@ class Ping(HostCase):
77 self.assertEqual(r["result"]["fmPath"], self.fm)
63 self.assertEqual(r["result"]["fmPath"], self.fm)
78 self.assertTrue(r["result"]["fmAvailable"])
64 self.assertTrue(r["result"]["fmAvailable"])
79 self.assertTrue(r["result"]["schemasOk"])
65 self.assertTrue(r["result"]["schemasOk"])
80− self.assertEqual(r["result"]["organizeMode"], "topics")
66+ self.assertEqual(r["result"]["organizeMode"], "nested")
82 def test_ping_model_unavailable(self):
68 def test_ping_model_unavailable(self):
83 self.control({"available": {"stderr": "Apple Intelligence is not enabled", "exit": 1}})
69 self.control({"available": {"stderr": "Apple Intelligence is not enabled", "exit": 1}})
@@ -92,22 +78,13 @@ class Ping(HostCase):
92 self.assertTrue(r["result"]["licenseRequired"])
78 self.assertTrue(r["result"]["licenseRequired"])
93 self.assertIn("sudo fm license", r["result"]["fmMessage"])
79 self.assertIn("sudo fm license", r["result"]["fmMessage"])
95− def test_ping_says_why_schemas_could_not_be_written(self):
96− for f in os.listdir(os.path.join(self.support, "schemas")):
97− os.remove(os.path.join(self.support, "schemas", f))
98− self.control({"schema_fail": True})
99− r = self.call("ping")
100− self.assertTrue(r["ok"], r)
101− self.assertFalse(r["result"]["schemasOk"])
102− self.assertIn("fm could not write", r["result"]["schemaMessage"])
104 def test_ping_writes_missing_schemas_once_fm_works(self):
81 def test_ping_writes_missing_schemas_once_fm_works(self):
105 for f in os.listdir(os.path.join(self.support, "schemas")):
82 for f in os.listdir(os.path.join(self.support, "schemas")):
106 os.remove(os.path.join(self.support, "schemas", f))
83 os.remove(os.path.join(self.support, "schemas", f))
107 r = self.call("ping")
84 r = self.call("ping")
108 self.assertTrue(r["ok"], r)
85 self.assertTrue(r["ok"], r)
109 self.assertTrue(r["result"]["schemasOk"])
86 self.assertTrue(r["result"]["schemasOk"])
110− self.assertEqual(sorted(f for f in os.listdir(os.path.join(self.support, "schemas")) if f != "version"), ["name.json", "topics.json"])
87+ self.assertEqual(sorted(os.listdir(os.path.join(self.support, "schemas"))), ["name.json", "organize.json"])
112 def test_ping_without_fm(self):
89 def test_ping_without_fm(self):
113 self.env["DIAGONAL_FM"] = os.path.join(self.tmp, "missing-fm")
90 self.env["DIAGONAL_FM"] = os.path.join(self.tmp, "missing-fm")
@@ -124,12 +101,10 @@ class Name(HostCase):
124 self.assertEqual(r["result"], {"title": "Rust async runtimes", "emoji": "🦀"})
101 self.assertEqual(r["result"], {"title": "Rust async runtimes", "emoji": "🦀"})
125 self.assertGreater(r["meta"]["promptChars"], 0)
102 self.assertGreater(r["meta"]["promptChars"], 0)
126 argv = [a for a in self.argv_log() if a[0] == "respond"][0]
103 argv = [a for a in self.argv_log() if a[0] == "respond"][0]
127− self.assertEqual(argv[:8], ["respond", "--model", "system", "--no-stream", "--greedy", "--schema", os.path.join(self.support, "schemas", "name.json"), "-i"])
104+ self.assertEqual(argv[:6], ["respond", "--model", "system", "--no-stream", "--schema", os.path.join(self.support, "schemas", "name.json")])
128− self.assertIn("You name a browser tab group", argv[8])
105+ self.assertEqual(argv[6], "--")
129− self.assertEqual(len(argv), 9)
130− # Tab text goes on stdin, never into argv where `ps` would show it.
131− self.assertIn("tokio.rs", self.stdin_log()[0])
106+ self.assertIn("tokio.rs", argv[7])
132− self.assertNotIn("tokio", " ".join(argv))
107+ self.assertEqual(len(argv), 8)
134 def test_json_wrapped_in_prose_and_fence(self):
109 def test_json_wrapped_in_prose_and_fence(self):
135 self.respond('Sure! Here it is:\n```json\n{"title": "Rust async", "emoji": "🦀"}\n```')
110 self.respond('Sure! Here it is:\n```json\n{"title": "Rust async", "emoji": "🦀"}\n```')
@@ -142,34 +117,16 @@ class Name(HostCase):
142 r = self.call("name", {"items": items})
117 r = self.call("name", {"items": items})
143 self.assertEqual(r["result"]["emoji"], "💻")
118 self.assertEqual(r["result"]["emoji"], "💻")
145− def test_pcc_falls_back_to_the_on_device_model(self):
120+ def test_pcc_only_when_asked(self):
146− # fm on macOS 27 rejects --model pcc; an older setting must not break naming.
147 self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
121 self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
148− r = self.call("name", {"items": ITEMS3}, model="pcc")
122+ self.call("name", {"items": ITEMS3}, model="pcc")
149− self.assertTrue(r["ok"], r)
150 argv = [a for a in self.argv_log() if a[0] == "respond"][0]
123 argv = [a for a in self.argv_log() if a[0] == "respond"][0]
151− self.assertEqual(argv[1:3], ["--model", "system"])
124+ self.assertEqual(argv[1:3], ["--model", "pcc"])
153− def test_guardrail_refusal_is_retried_permissively_once(self):
154− self.control({"respond_queue": [
155− {"stderr": "Error: The model's safety guardrails were triggered.\n", "exit": 1},
156− {"stdout": json.dumps({"title": "Rust async", "emoji": "🦀"})},
158− r = self.call("name", {"items": ITEMS3})
159− self.assertTrue(r["ok"], r)
160− calls = [a for a in self.argv_log() if a[0] == "respond"]
161− self.assertEqual(len(calls), 2)
162− self.assertNotIn("--guardrails", calls[0])
163− self.assertEqual(calls[1][-2:], ["--guardrails", "permissive-content-transformations"])
165− def test_context_overflow_is_over_budget(self):
166− self.respond(stderr="Error: The session's transcript exceeded the model's context size.\n", exit=1)
167− self.assertEqual(self.call("name", {"items": ITEMS3})["error"]["code"], "OVER_BUDGET")
169 def test_prompt_never_contains_tab_ids(self):
126 def test_prompt_never_contains_tab_ids(self):
170 self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
127 self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
171 self.call("name", {"items": [dict(it, id=987654) for it in ITEMS3]})
128 self.call("name", {"items": [dict(it, id=987654) for it in ITEMS3]})
172− prompt = self.stdin_log()[0]
129+ prompt = [a for a in self.argv_log() if a[0] == "respond"][0][-1]
173 self.assertNotIn("987654", prompt)
130 self.assertNotIn("987654", prompt)
@@ -203,52 +160,12 @@ class ErrorCodes(HostCase):
203 def test_over_budget_from_fm(self):
160 def test_over_budget_from_fm(self):
204 self.expect("OVER_BUDGET", stderr="The prompt exceeds the context window", exit=1)
161 self.expect("OVER_BUDGET", stderr="The prompt exceeds the context window", exit=1)
206− def test_page_text_is_shortened_before_giving_up(self):
207− # 40 tabs with long page text don't fit at 500 chars each, but do once the text is shortened.
208− items = [{"title": f"Page {n}", "url": f"https://example.com/{n}", "description": "word " * 100} for n in range(40)]
209− self.respond(json.dumps({"title": "Reading list", "emoji": "📚"}))
210− r = self.call("name", {"items": items})
211− self.assertTrue(r["ok"], r)
212− body = self.stdin_log()[0]
213− self.assertLessEqual(len(body) // 4, host.TOKEN_BUDGET)
214− self.assertIn("word", body)
216− def test_chinese_pages_are_measured_in_tokens(self):
217− # 30 Chinese tabs: few characters, but about twice the tokens per character of English. fm's own
218− # count says it's too long, so the page text is shortened.
219− items = [{"title": f"页面 {n}", "url": f"https://example.cn/{n}", "description": "中文内容" * 100} for n in range(30)]
220− self.control({"count_tokens": {"stdout": "9000\n", "exit": 0},
221− "respond": {"stdout": json.dumps({"title": "阅读清单", "emoji": "📚"}), "exit": 0}})
222− r = self.call("name", {"items": items})
223− self.assertTrue(r["ok"], r)
224− self.assertIn("count-tokens", [a[0] for a in self.argv_log()])
225− body = self.stdin_log()[0]
226− self.assertIn("中文内容", body)
227− self.assertNotIn("中文内容" * 60, body) # 500 characters didn't fit; 200 did
229− def test_over_budget_by_fms_count_reports_allowed_items(self):
230− items = [{"title": "中文标题" * 30, "url": f"https://example.cn/{n}"} for n in range(100)]
231− self.control({"count_tokens": {"stdout": "14000\n", "exit": 0}})
232− r = self.call("name", {"items": items})
233− self.assertEqual(r["error"]["code"], "OVER_BUDGET")
234− self.assertEqual(r["error"]["allowedItems"], 50)
236− def test_short_prompts_skip_the_exact_count(self):
237− self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
238− self.call("name", {"items": ITEMS3})
239− self.assertNotIn("count-tokens", [a[0] for a in self.argv_log()])
241− def test_estimate_covers_the_measured_counts(self):
242− # Measured with fm count-tokens on macOS 27 (tab lists): the estimate must never be lower.
243− for text, measured in (("a" * 3072, 810), ("я" * 1274, 400), ("中" * 476, 255), ("の" * 384, 195)):
244− self.assertGreaterEqual(host.estimate_tokens(host.prompts.Prompt("", text)), measured, text[0])
246 def test_over_budget_precheck_reports_allowed_items(self):
163 def test_over_budget_precheck_reports_allowed_items(self):
247− big = [{"title": "t" * 120, "url": "https://example.com/" + "p" * 280, "description": "d" * 300} for _ in range(120)]
164+ big = [{"title": "t" * 120, "url": "https://example.com/" + "p" * 280, "description": "d" * 300} for _ in range(30)]
248 r = self.call("name", {"items": big})
165 r = self.call("name", {"items": big})
249 self.assertEqual(r["error"]["code"], "OVER_BUDGET")
166 self.assertEqual(r["error"]["code"], "OVER_BUDGET")
250 self.assertGreaterEqual(r["error"]["allowedItems"], 1)
167 self.assertGreaterEqual(r["error"]["allowedItems"], 1)
251− self.assertLess(r["error"]["allowedItems"], 120)
168+ self.assertLess(r["error"]["allowedItems"], 30)
252 self.assertEqual([a for a in self.argv_log() if a[0] == "respond"], [])
169 self.assertEqual([a for a in self.argv_log() if a[0] == "respond"], [])
254 def test_guardrail(self):
171 def test_guardrail(self):
@@ -271,7 +188,6 @@ class ErrorCodes(HostCase):
272 def test_schema_missing(self):
189 def test_schema_missing(self):
273 os.remove(os.path.join(self.support, "schemas", "name.json"))
190 os.remove(os.path.join(self.support, "schemas", "name.json"))
274− self.control({"schema_fail": True}) # first-use repair fails too
275 r = self.call("name", {"items": ITEMS3})
191 r = self.call("name", {"items": ITEMS3})
276 self.assertEqual(r["error"]["code"], "SCHEMA_MISSING")
192 self.assertEqual(r["error"]["code"], "SCHEMA_MISSING")
@@ -283,21 +199,8 @@ class ErrorCodes(HostCase):
283 def test_strict_retry_appends_line(self):
199 def test_strict_retry_appends_line(self):
284 self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
200 self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
285 self.call("name", {"items": ITEMS3}, strict=True)
201 self.call("name", {"items": ITEMS3}, strict=True)
286− argv = [a for a in self.argv_log() if a[0] == "respond"][0]
202+ prompt = [a for a in self.argv_log() if a[0] == "respond"][0][-1]
287− self.assertIn("Reply with only the object", argv[argv.index("-i") + 1])
203+ self.assertIn("Reply with only the object", prompt)
290− def test_strict_retry_samples_instead_of_repeating_the_greedy_answer(self):
291− self.respond(json.dumps({"title": "Rust async", "emoji": "🦀"}))
292− self.call("name", {"items": ITEMS3}, strict=True)
293− self.assertNotIn("--greedy", [a for a in self.argv_log() if a[0] == "respond"][0])
295− def test_permissive_retry_shares_the_request_deadline(self):
296− # The first call uses up the request's time: no second call runs past what the extension waits for.
297− self.control({"respond_queue": [{"stderr": "Error: The model's safety guardrails were triggered.\n", "exit": 1, "sleep": 2.5}]})
298− r = self.call("name", {"items": ITEMS3}, timeoutMs=5000)
299− self.assertEqual(r["error"]["code"], "GUARDRAIL")
300− self.assertEqual(len([a for a in self.argv_log() if a[0] == "respond"]), 1)
303class Organize(HostCase):
206class Organize(HostCase):
@@ -305,166 +208,55 @@ class Organize(HostCase):
305 {"title": "Hotels in Lisbon", "url": "https://booking.com/lisbon"},
208 {"title": "Hotels in Lisbon", "url": "https://booking.com/lisbon"},
306 {"title": "Lisbon travel guide", "url": "https://lonelyplanet.com/portugal/lisbon"},
209 {"title": "Lisbon travel guide", "url": "https://lonelyplanet.com/portugal/lisbon"},
307 {"title": "Random page", "url": "https://example.org/"},
210 {"title": "Random page", "url": "https://example.org/"},
308− {"title": "Pasta recipe", "url": "https://cooking.example/pasta"},
309− {"title": "Rice recipe", "url": "https://cooking.example/rice"},
311− EXISTING = [{"g": 0, "title": "Lisbon trip", "samples": ["Flights to Lisbon", "Lisbon metro map"]}]
313− def topics(self, pairs):
213+ def test_nested_path(self):
314− self.respond(json.dumps({"tabs": [{"index": i, "topic": t} for i, t in pairs]}))
214+ self.respond(json.dumps({"groups": [
315−
215+ {"title": "Rust async", "emoji": "🦀", "color": "orange", "existing": -1, "members": [0, 1, 2]},
316− def test_tabs_with_one_topic_form_groups_and_join_existing_ones(self):
216+ {"title": "", "emoji": "", "color": "", "existing": 0, "members": [3, 4]},
317− # Indexes 8 and 9 are the existing group's example tabs (numbered after the 8 tabs to sort).
217+ ], "leftovers": [5]}))
318− self.topics([(0, "Programming"), (1, "programming"), (2, "Programming."), (3, "Travel"), (4, "travel"),
319− (5, "Other"), (6, "Cooking"), (7, "Cooking"), (8, "Travel"), (9, "Travel")])
320− r = self.call("organize", {"items": self.ITEMS, "existingGroups": self.EXISTING, "maxGroups": 4})
218+ r = self.call("organize", {"items": self.ITEMS, "existingGroups": [{"g": 0, "title": "Lisbon trip"}], "maxGroups": 2})
321 self.assertTrue(r["ok"], r)
219 self.assertTrue(r["ok"], r)
322− self.assertEqual(r["meta"]["path"], "topics")
323− groups = r["result"]["groups"]
324− self.assertEqual(groups[0], {"title": "", "emoji": "", "color": "", "existing": 0, "members": [3, 4]})
220+ self.assertEqual(r["result"]["groups"][0], {"title": "", "emoji": "", "color": "", "existing": 0, "members": [3, 4]})
325− new = sorted(g["members"] for g in groups[1:])
221+ self.assertEqual(r["result"]["groups"][1]["title"], "Rust async")
326− self.assertEqual(new, [[0, 1, 2], [6, 7]])
327− self.assertTrue(all(g["title"] == "" for g in groups[1:])) # the naming loop titles new groups
328 self.assertEqual(r["result"]["leftovers"], [5])
222 self.assertEqual(r["result"]["leftovers"], [5])
223+ self.assertEqual(r["meta"]["path"], "nested")
330− def test_new_groups_are_named_right_away(self):
225+ def test_two_call_fallback(self):
331− self.control({"respond_queue": [
226+ os.remove(os.path.join(self.support, "schemas", "organize.json"))
332− {"stdout": json.dumps({"tabs": [{"index": i, "topic": t} for i, t in
227+ for name in ("organize-labels.json", "organize-assign.json"):
333− [(0, "Programming"), (1, "Programming"), (2, "Programming"), (6, "Cooking"), (7, "Cooking")]]})},
228+ open(os.path.join(self.support, "schemas", name), "w").write("{}")
334− {"stdout": json.dumps({"title": "Rust async runtimes", "emoji": "🦀"})},
335− {"stderr": "Error: The model's safety guardrails were triggered.\n", "exit": 1},
336− {"stderr": "Error: The model's safety guardrails were triggered.\n", "exit": 1},
338− r = self.call("organize", {"items": self.ITEMS, "existingGroups": self.EXISTING, "maxGroups": 4})
339− self.assertTrue(r["ok"], r)
340− by_members = {tuple(g["members"]): g for g in r["result"]["groups"]}
341− self.assertEqual((by_members[(0, 1, 2)]["title"], by_members[(0, 1, 2)]["emoji"]), ("Rust async runtimes", "🦀"))
342− self.assertEqual(by_members[(6, 7)]["title"], "") # naming failed: the extension's naming loop takes it
343− name_calls = self.stdin_log()[1:]
344− self.assertIn("Tokio", name_calls[0])
345− self.assertIn('"Lisbon trip"', name_calls[0]) # existing titles are off limits
347− def test_a_new_group_named_like_another_group_is_left_to_the_naming_loop(self):
348 self.control({"respond_queue": [
229 self.control({"respond_queue": [
349− {"stdout": json.dumps({"tabs": [{"index": i, "topic": "Programming"} for i in (0, 1, 2)]})},
230+ {"stdout": json.dumps({"labels": ["Rust async"], "emojis": ["🦀"], "colors": ["orange"]})},
350− {"stdout": json.dumps({"title": "Rust async", "emoji": "🦀"})},
231+ {"stdout": json.dumps({"assignment": [1, 1, 1, 0, 0, -1]})},
352− r = self.call("organize", {"items": self.ITEMS, "siblingTitles": ["rust async"]})
233+ r = self.call("organize", {"items": self.ITEMS, "existingGroups": [{"g": 0, "title": "Lisbon trip"}], "maxGroups": 2})
353− self.assertEqual(r["result"]["groups"][0]["title"], "")
354− self.assertIn('"rust async"', self.stdin_log()[1]) # the name call was told, too
356− def test_a_topic_two_groups_share_equally_joins_neither_unless_the_words_say_which(self):
357− existing = [{"g": 0, "title": "Lisbon trip", "samples": ["Flights to Lisbon"]},
358− {"g": 1, "title": "Porto trip", "samples": ["Porto restaurants"]}]
359− # Examples 8 (group 0) and 9 (group 1) both got Travel: a tie.
360− self.topics([(3, "Travel"), (4, "Travel"), (8, "Travel"), (9, "Travel")])
361− r = self.call("organize", {"items": self.ITEMS, "existingGroups": existing})
362− self.assertEqual([g.get("existing") for g in r["result"]["groups"]], [0]) # "Lisbon" is in both tabs' titles
363− items = [{"title": "Beach day", "url": "https://a.example/"}, {"title": "City walk", "url": "https://b.example/"}]
364− self.topics([(0, "Travel"), (1, "Travel"), (2, "Travel"), (3, "Travel")])
365− r = self.call("organize", {"items": items, "existingGroups": existing})
366− self.assertNotIn("existing", r["result"]["groups"][0])
368− def test_only_vague_topics_leave_every_tab_loose(self):
369− self.topics([(0, "Other"), (1, "Misc"), (2, "General")])
370− r = self.call("organize", {"items": ITEMS3})
371 self.assertTrue(r["ok"], r)
234 self.assertTrue(r["ok"], r)
372− self.assertEqual(r["result"], {"groups": [], "leftovers": [0, 1, 2]})
235+ self.assertEqual(r["meta"]["path"], "two-call")
374− def test_two_topics_joining_one_group_make_one_join(self):
375− # The group's two example tabs (8 and 9) got different topics; both topics still join group 0 once.
376− self.topics([(3, "Travel"), (4, "Hotels"), (8, "Travel"), (9, "Hotels"), (0, "Programming")])
377− r = self.call("organize", {"items": self.ITEMS, "existingGroups": self.EXISTING})
378− joins = [g for g in r["result"]["groups"] if "existing" in g]
236+ groups = {g.get("existing", -1): g for g in r["result"]["groups"]}
237+ self.assertEqual(groups[0]["members"], [3, 4])
379− self.assertEqual(joins, [{"title": "", "emoji": "", "color": "", "existing": 0, "members": [3, 4]}])
238+ self.assertEqual(groups[-1]["title"], "Rust async")
380−
239+ self.assertEqual(groups[-1]["members"], [0, 1, 2])
381− def test_fm_answers_greedily(self):
240+ self.assertEqual(r["result"]["leftovers"], [5])
382− self.topics([(0, "Programming")])
383− self.call("organize", {"items": self.ITEMS})
384− self.assertIn("--greedy", [a for a in self.argv_log() if a[0] == "respond"][0])
241+ prompts_sent = [a[-1] for a in self.argv_log() if a[0] == "respond"]
386− def test_topics_only_when_asked_not_to_name(self):
387− self.topics([(0, "Programming"), (1, "Programming"), (2, "Travel")])
388− r = self.call("organize", {"items": self.ITEMS, "nameGroups": False})
389− self.assertTrue(r["ok"], r)
390− self.assertEqual(len([a for a in self.argv_log() if a[0] == "respond"]), 1)
242+ self.assertEqual(len(prompts_sent), 2)
391−
243+ self.assertIn("1 | Rust async", prompts_sent[1])
392− def test_a_topic_of_one_tab_is_a_leftover(self):
393− self.topics([(0, "Programming"), (1, "Programming"), (2, "Rust"), (3, "Travel"), (4, "Travel"), (5, "Web"), (6, "Food"), (7, "Cooking")])
394− r = self.call("organize", {"items": self.ITEMS, "maxGroups": 4})
395− self.assertEqual(sorted(g["members"] for g in r["result"]["groups"]), [[0, 1], [3, 4]])
396− self.assertEqual(r["result"]["leftovers"], [2, 5, 6, 7])
398− def test_prompt_rules_in_instructions_and_tabs_on_stdin(self):
399− self.topics([(0, "Programming")])
400− self.call("organize", {"items": self.ITEMS, "existingGroups": self.EXISTING})
401− argv = [a for a in self.argv_log() if a[0] == "respond"][0]
402− instructions = argv[argv.index("-i") + 1]
403− self.assertIn("broad topics", instructions)
404− self.assertNotIn("Lisbon", " ".join(argv)) # no tab or group text in the process list
405− body = self.stdin_log()[0]
406− self.assertIn("4 | Lisbon travel guide", body)
407− self.assertIn("8 | Flights to Lisbon | - | -", body)
409− def test_no_topics_is_bad_output(self):
410− self.respond(json.dumps({"tabs": []}))
411− r = self.call("organize", {"items": self.ITEMS})
412− self.assertEqual(r["error"]["code"], "BAD_MODEL_OUTPUT")
414− def test_context_overflow_suggests_a_smaller_batch(self):
415− self.respond(stderr="Error: The session's transcript exceeded the model's context size.\n", exit=1)
416− r = self.call("organize", {"items": self.ITEMS})
417− self.assertEqual(r["error"]["code"], "OVER_BUDGET")
418− self.assertEqual(r["error"]["allowedItems"], 5)
421class InstallSchemas(HostCase):
246class InstallSchemas(HostCase):
422− def test_writes_name_and_topics(self):
247+ def test_nested_supported(self):
423 for f in os.listdir(os.path.join(self.support, "schemas")):
248 for f in os.listdir(os.path.join(self.support, "schemas")):
424 os.remove(os.path.join(self.support, "schemas", f))
249 os.remove(os.path.join(self.support, "schemas", f))
425 code, _, p = self.run_host(args=["--install-schemas"])
250 code, _, p = self.run_host(args=["--install-schemas"])
426 self.assertEqual(code, 0, p.stderr)
251 self.assertEqual(code, 0, p.stderr)
427− self.assertEqual(sorted(os.listdir(os.path.join(self.support, "schemas"))), ["name.json", "topics.json", "version"])
252+ self.assertEqual(sorted(os.listdir(os.path.join(self.support, "schemas"))), ["name.json", "organize.json"])
429− def test_nested_object_gets_its_schema_from_fm_first(self):
254+ def test_falls_back_to_two_flat_schemas(self):
430− # Real fm: `--object tabs` must be followed by `--schema <json>` for the item's own schema.
255+ self.control({"schema_nested": False})
431− for f in os.listdir(os.path.join(self.support, "schemas")):
432− os.remove(os.path.join(self.support, "schemas", f))
433 code, _, p = self.run_host(args=["--install-schemas"])
256 code, _, p = self.run_host(args=["--install-schemas"])
434 self.assertEqual(code, 0, p.stderr)
257 self.assertEqual(code, 0, p.stderr)
435− calls = [a for a in self.argv_log() if a[0] == "schema"]
436− self.assertTrue(any("TabTopic" in a for a in calls))
437− topics = next(a for a in calls if "Topics" in a)
438− i = topics.index("--object")
439− self.assertEqual(topics[i + 1:i + 3], ["tabs", "--schema"])
440− self.assertIn('"args"', topics[i + 3]) # the fake fm's output for the TabTopic schema
441− self.assertEqual(topics[i + 4], "--array")
443− def test_schemas_from_an_older_host_are_replaced(self):
444− d = os.path.join(self.support, "schemas")
258+ self.assertEqual(sorted(os.listdir(os.path.join(self.support, "schemas"))), ["name.json", "organize-assign.json", "organize-labels.json"])
445− for f in os.listdir(d):
446− os.remove(os.path.join(d, f))
447− for name in ("name.json", "organize-labels.json", "organize-assign.json"):
448− open(os.path.join(d, name), "w").write("{}")
449− r = self.call("ping")
450− self.assertTrue(r["result"]["schemasOk"], r)
451− self.assertEqual(r["result"]["organizeMode"], "topics")
452− self.assertEqual(sorted(os.listdir(d)), ["name.json", "topics.json", "version"])
454− def test_first_use_writes_missing_schemas(self):
455− for f in os.listdir(os.path.join(self.support, "schemas")):
456− os.remove(os.path.join(self.support, "schemas", f))
457− self.respond(json.dumps({"title": "Rust async runtimes", "emoji": "🦀"}))
458− r = self.call("name", {"items": ITEMS3})
459− self.assertTrue(r["ok"], r)
460− self.assertIn("name.json", os.listdir(os.path.join(self.support, "schemas")))
462− def test_first_use_without_the_terms_says_so(self):
463− for f in os.listdir(os.path.join(self.support, "schemas")):
464− os.remove(os.path.join(self.support, "schemas", f))
465− self.control({"license": True})
466− r = self.call("name", {"items": ITEMS3})
467− self.assertEqual(r["error"]["code"], "LICENSE_REQUIRED", r)
469 def test_license_stops_early_without_claiming_nesting_is_unsupported(self):
261 def test_license_stops_early_without_claiming_nesting_is_unsupported(self):
470 for f in os.listdir(os.path.join(self.support, "schemas")):
262 for f in os.listdir(os.path.join(self.support, "schemas")):
@@ -479,70 +271,6 @@ class InstallSchemas(HostCase):
479 self.assertEqual(len(self.argv_log()), 1) # stopped after the first refusal
271 self.assertEqual(len(self.argv_log()), 1) # stopped after the first refusal
482−class Register(HostCase):
483− def make_profile(self, rel, browsing=True):
484− d = os.path.join(self.tmp, "appsupport", rel)
485− os.makedirs(os.path.join(d, "Default"))
486− open(os.path.join(d, "Local State"), "w").write("{}")
488− open(os.path.join(d, "Default", "History"), "w").write("")
493− self.env["DIAGONAL_APP_SUPPORT"] = os.path.join(self.tmp, "appsupport")
494− self.host_copy = os.path.join(self.tmp, "bin", "diagonal-host.py")
495− os.makedirs(os.path.dirname(self.host_copy))
496− shutil.copy(HOST_SCRIPT, self.host_copy)
497− for f in ("prompts.py", "validate.py", "emoji.txt"):
498− shutil.copy(os.path.join(HOST_DIR, f), os.path.dirname(self.host_copy))
500− def run_copy(self, *args):
501− return subprocess.run([sys.executable, self.host_copy, *args], capture_output=True, text=True, env=self.env, timeout=30)
503− def test_registers_with_every_chromium_browser_and_only_those(self):
504− origin = self.make_profile("BraveSoftware/Brave-Origin")
505− chrome = self.make_profile("Google/Chrome")
506− arc = self.make_profile("Arc/User Data")
507− unknown = self.make_profile("SomeVendor/NewBrowser")
508− electron = self.make_profile("Slack", browsing=False)
509− p = self.run_copy("--register", self.host_copy)
510− self.assertEqual(p.returncode, 0, p.stderr)
511− for d in (origin, chrome, arc, unknown):
512− m = json.load(open(os.path.join(d, "NativeMessagingHosts", "io.diagonal.host.json")))
513− self.assertEqual(m["path"], self.host_copy)
514− self.assertEqual(m["allowed_origins"], [host.ALLOWED_ORIGIN])
515− self.assertFalse(os.path.exists(os.path.join(electron, "NativeMessagingHosts")))
516− self.assertIn("registered: BraveSoftware/Brave-Origin", p.stdout)
518− p = self.run_copy("--unregister")
519− self.assertEqual(p.returncode, 0, p.stderr)
520− for d in (origin, chrome, arc, unknown):
521− self.assertFalse(os.path.exists(os.path.join(d, "NativeMessagingHosts", "io.diagonal.host.json")))
523− def test_names_a_host_that_cannot_run(self):
524− self.make_profile("Google/Chrome")
525− os.chmod(self.host_copy, 0o644)
526− p = self.run_copy("--register", self.host_copy)
527− self.assertEqual(p.returncode, 1)
528− self.assertIn("missing or not executable", p.stderr)
530− def test_one_browser_that_cannot_be_written_does_not_fail_the_rest(self):
531− chrome = self.make_profile("Google/Chrome")
532− brave = self.make_profile("BraveSoftware/Brave-Browser")
533− open(os.path.join(brave, "NativeMessagingHosts"), "w").close() # a file where the folder should be
534− p = self.run_copy("--register", self.host_copy)
535− self.assertEqual(p.returncode, 0, p.stderr)
536− self.assertIn("BraveSoftware/Brave-Browser: could not write", p.stderr)
537− self.assertTrue(os.path.exists(os.path.join(chrome, "NativeMessagingHosts", "io.diagonal.host.json")))
539− def test_no_browser_found(self):
540− os.makedirs(self.env["DIAGONAL_APP_SUPPORT"])
541− p = self.run_copy("--register", self.host_copy)
542− self.assertEqual(p.returncode, 1)
543− self.assertIn("No Chromium browser found", p.stderr)
546class SelfTest(HostCase):
274class SelfTest(HostCase):
547 def test_selftest_passes_with_a_working_fm(self):
275 def test_selftest_passes_with_a_working_fm(self):
548 self.respond(json.dumps({"title": "Rust async runtimes", "emoji": "🦀"}))
276 self.respond(json.dumps({"title": "Rust async runtimes", "emoji": "🦀"}))
@@ -557,13 +285,6 @@ class SelfTest(HostCase):
557 self.assertEqual(code, 1)
285 self.assertEqual(code, 1)
558 self.assertIn("FAIL model available", p.stdout.decode())
286 self.assertIn("FAIL model available", p.stdout.decode())
560− def test_selftest_without_fm_says_so_and_stops(self):
561− self.env["DIAGONAL_FM"] = os.path.join(self.tmp, "no-such-fm")
562− code, _, p = self.run_host(args=["--selftest"])
563− self.assertEqual(code, 1)
564− self.assertIn("FAIL fm found", p.stdout.decode())
565− self.assertNotIn("Traceback", p.stderr.decode())
567 def test_selftest_names_the_license_fix(self):
288 def test_selftest_names_the_license_fix(self):
568 self.control({"license": True})
289 self.control({"license": True})
569 code, _, p = self.run_host(args=["--selftest"])
290 code, _, p = self.run_host(args=["--selftest"])