diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index 34f1409c..25b61bc9 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -67,6 +67,21 @@ for f in core-faststart.mp4 core-moov-end.mp4 core.mov core.m4v core.mkv core-ta done ``` +# Local Inspect projection cases + +`local-inspect/projection-cases.json` holds synthetic record sets (prompt, workflow, +parameters and pair-record texts, optional `record_formats`, a video `carrier_text` +and `limited` records) that exercise every branch of the server's inspection +projection: Python's Decimal spellings and exact-construction limits, the 4300-digit +integer limit, duplicate keys, NaN/Infinity, the depth-64 and node ceilings, prompt +node key filtering and the carrier rule. `records: null` stands for an unreadable +file. `expect` pins both graph statuses; `needs` names an interpreter feature a case +depends on. `tests/js/workspace-local-projection.test.js` runs them through +`web/js/workspace/local-projection.mjs`, and `tests/test_local_inspect_parity.py` +asserts that its answer equals `utils.inspection_response._metadata` byte for byte. +All texts are original synthetic content, dedicated to the public domain under +CC0-1.0. + # Inspection parity fixtures `inspection_parity/` holds small synthetic PNGs (`f01b.png` ... `f24.png`, each diff --git a/tests/fixtures/local-inspect/projection-cases.json b/tests/fixtures/local-inspect/projection-cases.json new file mode 100644 index 00000000..657f64de --- /dev/null +++ b/tests/fixtures/local-inspect/projection-cases.json @@ -0,0 +1,849 @@ +{ + "version": 1, + "about": "Synthetic record sets for utils/inspection_response.py _metadata parity with web/js/workspace/local-projection.mjs projectMetadata. records null = an unreadable file. record_formats default to original-text for each record. expect pins both graph statuses; needs names an interpreter feature the Python side must have.", + "cases": [ + { + "name": "unreadable_read", + "records": null, + "expect": { + "prompt_graph": "absent", + "workflow_graph": "absent" + } + }, + { + "name": "absent_records", + "records": {}, + "expect": { + "prompt_graph": "absent", + "workflow_graph": "absent" + } + }, + { + "name": "prompt_and_workflow_parsed", + "records": { + "prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}", + "workflow": "{\"nodes\":[],\"version\":0.4}" + }, + "expect": { + "prompt_graph": "parsed", + "workflow_graph": "parsed" + } + }, + { + "name": "all_four_records", + "records": { + "prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}", + "workflow": "{\"nodes\":[],\"version\":0.4}", + "parameters": "cat\nNegative prompt: dog\nSteps: 20, Sampler: Euler, Seed: 7", + "comfyassets_prompt_pairs": "{\"version\":1,\"nodes\":{}}" + }, + "expect": { + "prompt_graph": "parsed", + "workflow_graph": "parsed" + } + }, + { + "name": "parameters_only", + "records": { + "parameters": "cat\nSteps: 20" + }, + "expect": { + "prompt_graph": "absent", + "workflow_graph": "absent" + } + }, + { + "name": "number_spellings", + "records": { + "workflow": "{\"nodes\":[],\"a\":[1e3,1e0,-0.0,6.50,0.0100,1.0e2,0e5,0.0000001,0.000001,1E+3,1e-0,0.5e1,0.05e1,0.0e0,1.5e+300,123e-8,2.5e-3,1.0,-1.50,0.0,100.0,1234567e-6,0.5E-6,0.5E-5,1e22,0.000000,-0.000000e-3,7.0e-6,7.0e-7,12345678901234567890.123,9007199254740992.0]}" + }, + "expect": { + "prompt_graph": "absent", + "workflow_graph": "parsed" + } + }, + { + "name": "integer_spellings", + "records": { + "workflow": "{\"a\":[9007199254740991,-9007199254740991,9007199254740992,-9007199254740992,10000000000000000,123456789012345678901234567890,0,-0,42,-7,100]}" + }, + "expect": { + "prompt_graph": "absent", + "workflow_graph": "parsed" + } + }, + { + "name": "nonfinite_literals_are_null", + "records": { + "prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"n\":NaN,\"i\":Infinity,\"m\":-Infinity},\"_meta\":{\"n\":NaN}}}", + "workflow": "{\"a\":[NaN,Infinity,-Infinity],\"b\":NaN}" + }, + "expect": { + "prompt_graph": "parsed", + "workflow_graph": "parsed" + } + }, + { + "name": "exponent_edges_valid", + "records": { + "workflow": "{\"a\":[1e999999999999999999,1e-1999999999999999997,0.0e1000000000000000000,1.5e999999999999999999,0.1e-1999999999999999996]}" + }, + "expect": { + "prompt_graph": "absent", + "workflow_graph": "parsed" + } + }, + { + "name": "exponent_edge_invalid_1e1000000000000000000", + "records": { + "workflow": "{\"a\":1e1000000000000000000}" + }, + "expect": { + "prompt_graph": "absent", + "workflow_graph": "malformed" + } + }, + { + "name": "exponent_edge_invalid_15e999999999999999999", + "records": { + "workflow": "{\"a\":15e999999999999999999}" + }, + "expect": { + "prompt_graph": "absent", + "workflow_graph": "malformed" + } + }, + { + "name": "exponent_edge_invalid_1e_1999999999999999998", + "records": { + "workflow": "{\"a\":1e-1999999999999999998}" + }, + "expect": { + "prompt_graph": "absent", + "workflow_graph": "malformed" + } + }, + { + "name": "exponent_edge_invalid_1_5e_1999999999999999997", + "records": { + "workflow": "{\"a\":1.5e-1999999999999999997}" + }, + "expect": { + "prompt_graph": "absent", + "workflow_graph": "malformed" + } + }, + { + "name": "exponent_edge_invalid_0e1000000000000000000", + "records": { + "workflow": "{\"a\":0e1000000000000000000}" + }, + "expect": { + "prompt_graph": "absent", + "workflow_graph": "malformed" + } + }, + { + "name": "exponent_edge_invalid_1e9223372036854775808", + "records": { + "workflow": "{\"a\":1e9223372036854775808}" + }, + "expect": { + "prompt_graph": "absent", + "workflow_graph": "malformed" + } + }, + { + "name": "exponent_edge_invalid_0_0e1000000000000000001", + "records": { + "workflow": "{\"a\":0.0e1000000000000000001}" + }, + "expect": { + "prompt_graph": "absent", + "workflow_graph": "malformed" + } + }, + { + "name": "exponent_edge_invalid_1e99999999999999999999999999", + "records": { + "workflow": "{\"a\":1e99999999999999999999999999}" + }, + "expect": { + "prompt_graph": "absent", + "workflow_graph": "malformed" + } + }, + { + "name": "int_4300_digits_kept", + "records": { + "workflow": "{\"a\":7777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777}" + }, + "expect": { + "prompt_graph": "absent", + "workflow_graph": "parsed" + } + }, + { + "name": "int_4301_digits_malformed", + "records": { + "workflow": "{\"a\":77777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777}" + }, + "needs": "int-max-str-digits", + "expect": { + "prompt_graph": "absent", + "workflow_graph": "malformed" + } + }, + { + "name": "int_negative_4301_digits_malformed", + "records": { + "workflow": "{\"a\":-77777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777}" + }, + "needs": "int-max-str-digits", + "expect": { + "prompt_graph": "absent", + "workflow_graph": "malformed" + } + }, + { + "name": "string_escapes", + "records": { + "prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"t\":\"é😀\\u0000\\u001f/\\\\\\\"\\b\\f\\n\\r\\t\",\"raw\":\"é😀 — 
\"}}}", + "workflow": "{\"s\":\"\\u00E9\\uD83D\\uDE00\\uD800\\uDC00x\\u0000\\u001f\\/\\\\\\\"\\b\\f\\n\\r\\t\\uD800\\u0041\\uDC00\"}" + }, + "expect": { + "prompt_graph": "parsed", + "workflow_graph": "parsed" + } + }, + { + "name": "lone_surrogate_in_raw_text", + "records": { + "prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"t\":\"𐏿\\udc00\"}}}", + "parameters": "\ud800 lone\nSteps: 1" + }, + "expect": { + "prompt_graph": "parsed", + "workflow_graph": "absent" + } + }, + { + "name": "keys_with_escapes_proto_and_order", + "records": { + "prompt": "{\"10\":{\"class_type\":\"B\",\"inputs\":{}},\"3\":{\"class_type\":\"A\",\"inputs\":{}},\"__proto__\":{\"class_type\":\"P\",\"inputs\":{\"constructor\":1}},\"constructor\":{\"class_type\":\"C\",\"inputs\":{}},\"\\u0000\":5,\"\":[]}", + "workflow": "{\"10\":1,\"3\":2,\"b\":3,\"a\":4,\"__proto__\":{\"x\":1},\"\":{}}" + }, + "expect": { + "prompt_graph": "parsed", + "workflow_graph": "parsed" + } + }, + { + "name": "duplicate_key_top_level", + "records": { + "workflow": "{\"a\":1,\"a\":2}" + }, + "expect": { + "prompt_graph": "absent", + "workflow_graph": "malformed" + } + }, + { + "name": "duplicate_key_nested", + "records": { + "workflow": "{\"a\":{\"b\":[{\"c\":1,\"c\":1}]}}" + }, + "expect": { + "prompt_graph": "absent", + "workflow_graph": "malformed" + } + }, + { + "name": "duplicate_key_in_dropped_prompt_meta", + "records": { + "prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"a\":1},\"_meta\":{\"t\":1,\"t\":2}}}" + }, + "expect": { + "prompt_graph": "malformed", + "workflow_graph": "absent" + } + }, + { + "name": "duplicate_key_in_prompt_inputs", + "records": { + "prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"a\":1,\"a\":1}}}" + }, + "expect": { + "prompt_graph": "malformed", + "workflow_graph": "absent" + } + }, + { + "name": "duplicate_key_after_deep_nest", + "records": { + "workflow": "{\"x\":[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]],\"a\":1,\"a\":2}" + }, + "expect": { + "prompt_graph": "absent", + "workflow_graph": "malformed" + } + }, + { + "name": "workflow_depth_64_kept", + "records": { + "workflow": "{\"a\":[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]}" + }, + "expect": { + "prompt_graph": "absent", + "workflow_graph": "parsed" + } + }, + { + "name": "workflow_depth_65_limited", + "records": { + "workflow": "{\"a\":[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]}" + }, + "expect": { + "prompt_graph": "absent", + "workflow_graph": "limited" + } + }, + { + "name": "prompt_inputs_depth_64_kept", + "records": { + "prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"v\":[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]}}}" + }, + "expect": { + "prompt_graph": "parsed", + "workflow_graph": "absent" + } + }, + { + "name": "prompt_inputs_depth_65_limited", + "records": { + "prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"v\":[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]}}}" + }, + "expect": { + "prompt_graph": "limited", + "workflow_graph": "absent" + } + }, + { + "name": "prompt_dropped_meta_depth_65_limited", + "records": { + "prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{},\"_meta\":{\"v\":[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]}}}" + }, + "expect": { + "prompt_graph": "limited", + "workflow_graph": "absent" + } + }, + { + "name": "prompt_non_object_nodes_kept", + "records": { + "prompt": "{\"1\":5,\"2\":[1,2.50,\"x\",null,true],\"3\":\"s\",\"4\":null,\"5\":{\"inputs\":[1],\"class_type\":null,\"other\":{\"deep\":[NaN]}}}" + }, + "expect": { + "prompt_graph": "parsed", + "workflow_graph": "absent" + } + }, + { + "name": "prompt_root_array_malformed", + "records": { + "prompt": "[]" + }, + "expect": { + "prompt_graph": "malformed", + "workflow_graph": "absent" + } + }, + { + "name": "prompt_root_string_malformed", + "records": { + "prompt": "\"{}\"" + }, + "expect": { + "prompt_graph": "malformed", + "workflow_graph": "absent" + } + }, + { + "name": "prompt_root_number_malformed", + "records": { + "prompt": "5" + }, + "expect": { + "prompt_graph": "malformed", + "workflow_graph": "absent" + } + }, + { + "name": "prompt_root_null_malformed", + "records": { + "prompt": "null" + }, + "expect": { + "prompt_graph": "malformed", + "workflow_graph": "absent" + } + }, + { + "name": "workflow_root_array_malformed", + "records": { + "workflow": "[1,2]" + }, + "expect": { + "prompt_graph": "absent", + "workflow_graph": "malformed" + } + }, + { + "name": "workflow_root_nan_malformed", + "records": { + "workflow": "NaN" + }, + "expect": { + "prompt_graph": "absent", + "workflow_graph": "malformed" + } + }, + { + "name": "empty_text_malformed", + "records": { + "prompt": "", + "workflow": " " + }, + "expect": { + "prompt_graph": "malformed", + "workflow_graph": "malformed" + } + }, + { + "name": "bom_malformed", + "records": { + "workflow": "{}" + }, + "expect": { + "prompt_graph": "absent", + "workflow_graph": "malformed" + } + }, + { + "name": "trailing_data_malformed", + "records": { + "workflow": "{\"a\":1} x", + "prompt": "{}\u0000" + }, + "expect": { + "prompt_graph": "malformed", + "workflow_graph": "malformed" + } + }, + { + "name": "python_whitespace_only", + "records": { + "workflow": " \t\r\n{\n\t\"a\" :\r 1 }\n", + "prompt": "\u000b{}" + }, + "expect": { + "prompt_graph": "malformed", + "workflow_graph": "parsed" + } + }, + { + "name": "syntax_errors", + "records": { + "prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{},}}", + "workflow": "[1,]" + }, + "expect": { + "prompt_graph": "malformed", + "workflow_graph": "malformed" + } + }, + { + "name": "bad_escape_and_control_char", + "records": { + "prompt": "{\"1\":\"\\x\"}", + "workflow": "{\"a\":\"\u0001\"}" + }, + "expect": { + "prompt_graph": "malformed", + "workflow_graph": "malformed" + } + }, + { + "name": "number_grammar", + "records": { + "prompt": "{\"1\":01}", + "workflow": "{\"a\":1.,\"b\":.5}" + }, + "expect": { + "prompt_graph": "malformed", + "workflow_graph": "malformed" + } + }, + { + "name": "nodes_not_list_counts_zero", + "records": { + "workflow": "{\"nodes\":{\"a\":1},\"definitions\":{\"subgraphs\":[{\"nodes\":5},7,{\"nodes\":[1,2]}]}}" + }, + "expect": { + "prompt_graph": "absent", + "workflow_graph": "parsed" + } + }, + { + "name": "limited_workflow_record", + "records": { + "prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}", + "workflow": "{\"nodes\":[],\"version\":0.4}" + }, + "limited": [ + "workflow" + ], + "expect": { + "prompt_graph": "parsed", + "workflow_graph": "limited" + } + }, + { + "name": "limited_workflow_without_text", + "records": { + "prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}" + }, + "limited": [ + "workflow" + ], + "expect": { + "prompt_graph": "parsed", + "workflow_graph": "limited" + } + }, + { + "name": "invalid_workflow_format", + "records": { + "prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}" + }, + "record_formats": { + "prompt": "original-text", + "workflow": "invalid" + }, + "expect": { + "prompt_graph": "parsed", + "workflow_graph": "malformed" + } + }, + { + "name": "reserialized_without_carrier_is_limited", + "records": { + "prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}", + "workflow": "{\"nodes\":[],\"version\":0.4}" + }, + "record_formats": { + "prompt": "reserialized-json", + "workflow": "reserialized-json" + }, + "expect": { + "prompt_graph": "limited", + "workflow_graph": "limited" + } + }, + { + "name": "unknown_format_dropped_but_projected", + "records": { + "prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}" + }, + "record_formats": { + "prompt": "weird" + }, + "expect": { + "prompt_graph": "parsed", + "workflow_graph": "absent" + } + }, + { + "name": "non_text_record_dropped", + "records": { + "prompt": 5, + "workflow": "{\"nodes\":[],\"version\":0.4}" + }, + "record_formats": { + "prompt": "original-text", + "workflow": "original-text" + }, + "expect": { + "prompt_graph": "malformed", + "workflow_graph": "parsed" + } + }, + { + "name": "format_without_text_is_malformed", + "records": {}, + "record_formats": { + "prompt": "original-text" + }, + "expect": { + "prompt_graph": "malformed", + "workflow_graph": "absent" + } + }, + { + "name": "unknown_record_keys_ignored", + "records": { + "prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}", + "Software": "ComfyUI", + "WORKFLOW": "{\"nodes\":[],\"version\":0.4}" + }, + "record_formats": { + "prompt": "original-text", + "Software": "original-text", + "WORKFLOW": "original-text" + }, + "expect": { + "prompt_graph": "parsed", + "workflow_graph": "absent" + } + }, + { + "name": "carrier_prompt_string_workflow_object", + "records": { + "prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}", + "workflow": "{\"nodes\": [], \"v\": 1}" + }, + "record_formats": { + "prompt": "original-text", + "workflow": "reserialized-json" + }, + "carrier_text": "{\"prompt\":\"{\\\"1\\\":{\\\"class_type\\\":\\\"A\\\",\\\"inputs\\\":{\\\"x\\\":1}}}\",\"workflow\":{\"nodes\":[],\"v\":1}}", + "expect": { + "prompt_graph": "parsed", + "workflow_graph": "parsed" + } + }, + { + "name": "carrier_prompt_object_projected", + "records": { + "prompt": "{\"1\": {\"class_type\": \"A\", \"inputs\": {\"x\": 8.0}, \"_meta\": {\"t\": \"x\"}}}", + "workflow": "{\"nodes\":[],\"version\":0.4}" + }, + "record_formats": { + "prompt": "reserialized-json", + "workflow": "original-text" + }, + "carrier_text": "{\"prompt\":{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":8.0},\"_meta\":{\"t\":\"x\"}}},\"workflow\":{\"nodes\":[],\"version\":0.4},\"title\":\"n\",\"extra\":[1e3]}", + "expect": { + "prompt_graph": "parsed", + "workflow_graph": "parsed" + } + }, + { + "name": "carrier_root_array_fails_all", + "records": { + "prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}", + "workflow": "{\"nodes\":[],\"version\":0.4}" + }, + "carrier_text": "[1]", + "expect": { + "prompt_graph": "malformed", + "workflow_graph": "malformed" + } + }, + { + "name": "carrier_duplicate_key_fails_all", + "records": { + "prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}" + }, + "carrier_text": "{\"prompt\":\"{}\",\"prompt\":\"{}\"}", + "expect": { + "prompt_graph": "malformed", + "workflow_graph": "absent" + } + }, + { + "name": "carrier_other_member_too_deep_limited", + "records": { + "prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}" + }, + "carrier_text": "{\"title\":[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]],\"prompt\":\"{\\\"1\\\":{\\\"class_type\\\":\\\"A\\\",\\\"inputs\\\":{\\\"x\\\":1}}}\"}", + "expect": { + "prompt_graph": "limited", + "workflow_graph": "absent" + } + }, + { + "name": "carrier_other_member_depth_64_kept", + "records": { + "prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}" + }, + "carrier_text": "{\"title\":[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]],\"prompt\":\"{\\\"1\\\":{\\\"class_type\\\":\\\"A\\\",\\\"inputs\\\":{\\\"x\\\":1}}}\"}", + "expect": { + "prompt_graph": "parsed", + "workflow_graph": "absent" + } + }, + { + "name": "carrier_prompt_object_depth_shift", + "records": { + "prompt": "x" + }, + "record_formats": { + "prompt": "reserialized-json" + }, + "carrier_text": "{\"prompt\":{\"1\":{\"class_type\":\"A\",\"inputs\":{\"v\":[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]}}}}", + "expect": { + "prompt_graph": "parsed", + "workflow_graph": "absent" + } + }, + { + "name": "carrier_prompt_object_depth_shift_limited", + "records": { + "prompt": "x" + }, + "record_formats": { + "prompt": "reserialized-json" + }, + "carrier_text": "{\"prompt\":{\"1\":{\"class_type\":\"A\",\"inputs\":{\"v\":[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]}}}}", + "expect": { + "prompt_graph": "limited", + "workflow_graph": "absent" + } + }, + { + "name": "carrier_prompt_string_invalid_json", + "records": { + "prompt": "{", + "workflow": "{\"nodes\":[],\"version\":0.4}" + }, + "carrier_text": "{\"prompt\":\"{\",\"workflow\":\"{\\\"nodes\\\":[],\\\"version\\\":0.4}\"}", + "expect": { + "prompt_graph": "malformed", + "workflow_graph": "parsed" + } + }, + { + "name": "carrier_prompt_string_too_deep", + "records": { + "prompt": "x" + }, + "carrier_text": "{\"prompt\":\"{\\\"1\\\":{\\\"class_type\\\":\\\"A\\\",\\\"inputs\\\":{\\\"v\\\":[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[[]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]]}}}\"}", + "expect": { + "prompt_graph": "limited", + "workflow_graph": "absent" + } + }, + { + "name": "carrier_workflow_number_malformed", + "records": { + "prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}", + "workflow": "5" + }, + "record_formats": { + "prompt": "original-text", + "workflow": "original-text" + }, + "carrier_text": "{\"prompt\":\"{\\\"1\\\":{\\\"class_type\\\":\\\"A\\\",\\\"inputs\\\":{\\\"x\\\":1}}}\",\"workflow\":5}", + "expect": { + "prompt_graph": "parsed", + "workflow_graph": "malformed" + } + }, + { + "name": "carrier_prompt_nonfinite_malformed", + "records": { + "prompt": "NaN" + }, + "carrier_text": "{\"prompt\":NaN}", + "expect": { + "prompt_graph": "malformed", + "workflow_graph": "absent" + } + }, + { + "name": "carrier_prompt_bigint_malformed", + "records": { + "prompt": "123456789012345678901" + }, + "carrier_text": "{\"prompt\":123456789012345678901}", + "expect": { + "prompt_graph": "malformed", + "workflow_graph": "absent" + } + }, + { + "name": "carrier_prompt_decimal_malformed", + "records": { + "prompt": "1.5" + }, + "carrier_text": "{\"prompt\":1.5}", + "expect": { + "prompt_graph": "malformed", + "workflow_graph": "absent" + } + }, + { + "name": "carrier_missing_member_malformed", + "records": { + "prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}" + }, + "carrier_text": "{\"workflow\":{}}", + "expect": { + "prompt_graph": "malformed", + "workflow_graph": "absent" + } + }, + { + "name": "carrier_without_graph_records", + "records": { + "parameters": "cat" + }, + "carrier_text": "{\"parameters\":\"cat\"}", + "expect": { + "prompt_graph": "absent", + "workflow_graph": "absent" + } + }, + { + "name": "carrier_with_limited_workflow", + "records": { + "prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}" + }, + "limited": [ + "workflow" + ], + "carrier_text": "{\"prompt\":\"{\\\"1\\\":{\\\"class_type\\\":\\\"A\\\",\\\"inputs\\\":{\\\"x\\\":1}}}\"}", + "expect": { + "prompt_graph": "parsed", + "workflow_graph": "limited" + } + }, + { + "name": "carrier_invalid_workflow_format", + "records": { + "prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}" + }, + "record_formats": { + "prompt": "original-text", + "workflow": "invalid" + }, + "carrier_text": "{\"prompt\":\"{\\\"1\\\":{\\\"class_type\\\":\\\"A\\\",\\\"inputs\\\":{\\\"x\\\":1}}}\",\"workflow\":null}", + "expect": { + "prompt_graph": "parsed", + "workflow_graph": "malformed" + } + }, + { + "name": "carrier_non_string_text_malformed", + "records": { + "prompt": "{\"1\":{\"class_type\":\"A\",\"inputs\":{\"x\":1}}}" + }, + "carrier_text": "", + "expect": { + "prompt_graph": "malformed", + "workflow_graph": "absent" + } + } + ] +} diff --git a/tests/js/support/local-inspect-oracle.mjs b/tests/js/support/local-inspect-oracle.mjs index fc3ffe83..b6898cc1 100644 --- a/tests/js/support/local-inspect-oracle.mjs +++ b/tests/js/support/local-inspect-oracle.mjs @@ -1,14 +1,19 @@ -// The browser's local readers run over files on disk, for +// The browser's local readers and projection run over files on disk, for // tests/test_local_inspect_parity.py, which compares every answer with the server's -// utils.prompt_inspector.read_inspection_file on the same file. Each file is opened -// as a Blob (fs.openAsBlob) so the reader slices it exactly as it slices a File. -// stdin: [{name, path} | {name, tags, limited?}] -// stdout: {name: {classified, result}} where result is readPngMetadataFile's +// utils.prompt_inspector.read_inspection_file and utils.inspection_response._metadata +// on the same file. Each file is opened as a Blob (fs.openAsBlob) so the reader +// slices it exactly as it slices a File. +// stdin: [{name, path} | {name, tags, limited?} | {name, records, record_formats?, carrier_text?, limited?}] +// stdout: {name: {classified, result, metadata}} where result is readPngMetadataFile's // answer for a PNG, readImageHeader's for another image, for a video // {read: readVideoTags(...), selection: selectVideoCarrier(read.tags, {limited})} -// (selection null unless the read completed), else null. -// A {tags} entry answers {classified: null, result: {selection}} for the -// carrier rule alone, as the server selects from ffprobe's tag map. +// (selection null unless the read completed), else null; metadata is +// projectMetadata(...) of the local read (the server's _metadata) for every +// classified file, else null. +// A {tags} entry answers {classified: null, result: {selection}, metadata} for +// the carrier rule alone, as the server selects from ffprobe's tag map. +// A {records} entry (records null = an unreadable file) answers +// {classified: null, result: null, metadata} for the projection alone. import fs from 'node:fs'; import path from 'node:path'; import { createRequire } from 'node:module'; @@ -16,28 +21,37 @@ import { fileURLToPath } from 'node:url'; const here = path.dirname(fileURLToPath(import.meta.url)); const require = createRequire(import.meta.url); -const { classifyPrefix, readPngMetadataFile, readImageHeader } = require(path.join(here, '..', '..', '..', 'web', 'js', 'png-metadata.js')); -const { readVideoTags, selectVideoCarrier } = await import(path.join(here, '..', '..', '..', 'web', 'js', 'workspace', 'local-video.mjs')); +const pngMetadata = require(path.join(here, '..', '..', '..', 'web', 'js', 'png-metadata.js')); +const web = path.join(here, '..', '..', '..', 'web', 'js', 'workspace'); +const { selectVideoCarrier } = await import(path.join(web, 'local-video.mjs')); +const { projectMetadata } = await import(path.join(web, 'local-projection.mjs')); +const { readLocalFile, toInspectionRead } = await import(path.join(web, 'local-inspection.mjs')); + +const selectionRead = (selection) => selection.status === 'complete' ? + { records: selection.records, record_formats: selection.record_formats, carrierText: selection.carrierText, limited: selection.limited } : null; const chunks = []; for await (const chunk of process.stdin) chunks.push(chunk); const output = {}; for (const entry of JSON.parse(Buffer.concat(chunks).toString('utf8'))) { const { name } = entry; + if (entry.records !== undefined) { + const read = entry.records === null ? null : { + records: entry.records, + record_formats: entry.record_formats ?? Object.fromEntries(Object.keys(entry.records).map((key) => [key, 'original-text'])), + carrierText: entry.carrier_text ?? null, limited: entry.limited ?? [], + }; + output[name] = { classified: null, result: null, metadata: projectMetadata(read) }; + continue; + } if (entry.path === undefined) { - output[name] = { classified: null, result: { selection: selectVideoCarrier(entry.tags, { limited: entry.limited || [] }) } }; + const selection = selectVideoCarrier(entry.tags, { limited: entry.limited || [] }); + output[name] = { classified: null, result: { selection }, metadata: projectMetadata(selectionRead(selection)) }; continue; } const file = await fs.openAsBlob(entry.path); - const prefix = new Uint8Array(await file.slice(0, Math.min(32, file.size)).arrayBuffer()); - const classified = classifyPrefix(prefix); - let result = null; - if (classified?.kind === 'png') result = await readPngMetadataFile(file, { prefix }); - else if (classified?.media === 'image') result = await readImageHeader(file, classified.kind); - else if (classified?.media === 'video') { - const read = await readVideoTags(file, { prefix, kind: classified.kind }); - result = { read, selection: read.status === 'complete' ? selectVideoCarrier(read.tags, { limited: read.limited }) : null }; - } - output[name] = { classified, result }; + const { classification, result } = await readLocalFile(file, { pngMetadata }); + const metadata = classification === null ? null : projectMetadata(toInspectionRead(classification, result).read); + output[name] = { classified: classification, result, metadata }; } process.stdout.write(JSON.stringify(output)); diff --git a/tests/js/workspace-local-inspection.test.js b/tests/js/workspace-local-inspection.test.js new file mode 100644 index 00000000..0fa8966d --- /dev/null +++ b/tests/js/workspace-local-inspection.test.js @@ -0,0 +1,308 @@ +// web/js/workspace/local-inspection.mjs turns one chosen File's local reads into +// the inspection wire the workspace already renders, fits it into the 8 MiB +// response budget exactly as the server does, and plugs into the shared +// inspection controller, inspector and viewer through localFileAdapter. +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { fileURLToPath } from "node:url"; +import { raw, ihdr, idat, iend, tEXt, iTXt, utf8 } from "./helpers/png-bytes.mjs"; +import { createInspectionController, toInspectionRecord, projectInspectionPayload } from "../../web/js/workspace/inspection-state.mjs"; +import { inspectionDownloads } from "../../web/js/workspace/file-inspector.mjs"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); +const PngMetadata = require("../../web/js/png-metadata.js"); +const ComfyMetadata = require("../../web/js/comfy-metadata.js"); +const { extractGenerationFacts } = ComfyMetadata; +// The page loads both as scripts; the legacy fill and the PNG reader find them on the global. +globalThis.PngMetadata = PngMetadata; +globalThis.ComfyMetadata = ComfyMetadata; +const m = await import("../../web/js/workspace/local-inspection.mjs").catch((error) => { + if (error.code !== "ERR_MODULE_NOT_FOUND") throw error; + return {}; +}); + +const FIXTURES = path.join(here, "..", "fixtures", "local-inspect"); +const PARITY = path.join(here, "..", "fixtures", "inspection_parity"); +const PROMPT = '{"3":{"class_type":"KSampler","inputs":{"seed":7,"steps":20,"cfg":8.0,"sampler_name":"euler","scheduler":"normal","denoise":1.0,"model":["4",0],"positive":["6",0],"negative":["7",0],"latent_image":["5",0]}},"4":{"class_type":"CheckpointLoaderSimple","inputs":{"ckpt_name":"synthetic-v1.safetensors"}},"5":{"class_type":"EmptyLatentImage","inputs":{"width":512,"height":512,"batch_size":1}},"6":{"class_type":"CLIPTextEncode","inputs":{"text":"a red fox in snow, detailed fur","clip":["4",1]}},"7":{"class_type":"CLIPTextEncode","inputs":{"text":"blurry, lowres","clip":["4",1]}}}'; +const WORKFLOW = '{"last_node_id":7,"nodes":[{"id":3,"type":"KSampler","widgets_values":[7,"fixed",20,8.0,"euler","normal",1.0]}],"links":[],"version":0.4}'; +const CONTAINER_WORKFLOW = "Original workflow JSON text is unavailable for this container record. Download metadata or the original file."; +const LIMIT = 8388608; +const plain = (value) => JSON.parse(JSON.stringify(value)); +const bytes = (value) => Buffer.byteLength(JSON.stringify(value), "utf8"); + +function pngFile(name, chunks, { lastModified = 1_700_000_000_123 } = {}) { + return new File([raw(ihdr(640, 480), idat(), ...chunks, iend())], name, { type: "image/png", lastModified }); +} +const fixtureFile = (name, directory = FIXTURES) => new File([fs.readFileSync(path.join(directory, name))], name, { lastModified: 1_700_000_000_000 }); +const classify = async (file) => PngMetadata.classifyPrefix(new Uint8Array(await file.slice(0, 32).arrayBuffer())); +async function handleFor(file, { url = "blob:http://host.test/11111111-2222-3333-4444-555555555555", filename } = {}) { + return m.createLocalFileHandle({ file, url, classification: await classify(file), filename }); +} +async function recordFor(file, options) { + const handle = await handleFor(file, options); + const wire = await m.localFileAdapter.read(handle); + return { handle, wire, record: m.localFileAdapter.toRecord(wire, handle, extractGenerationFacts) }; +} + +test("contract", () => { + for (const name of ["buildLocalPayload", "fitLocalPayload", "createLocalFileHandle", "readLocalFile", "toInspectionRead", "sanitizeLocalFilename"]) { + assert.equal(typeof m[name], "function", `${name} is exported`); + } + assert.equal(typeof m.localFileAdapter, "object"); + for (const name of ["validate", "key", "scope", "inspectionPath", "read", "toRecord", "originalDownload", "mediaUrl"]) { + assert.equal(typeof m.localFileAdapter[name], "function", `adapter.${name}`); + } + assert.ok(Object.isFrozen(m.localFileAdapter)); +}); + +test("a PNG File becomes the server-shaped wire with local provenance", async () => { + const file = pngFile("Fox Render.PNG", [tEXt("prompt", PROMPT), tEXt("workflow", WORKFLOW)]); + const { wire, record, handle } = await recordFor(file); + assert.deepEqual(wire.file, { observation: "selected-local-file", source_label: "Selected local file", media_type: "image", extension: ".png", format: "PNG", + size: file.size, width: 640, height: 480, modified_time: 1_700_000_000.123 }); + assert.equal(wire.success, true); + assert.equal(wire.inspection_version, 1); + assert.deepEqual(wire.prompts, { positive: { status: "unresolved" }, negative: { status: "unresolved" } }); + assert.deepEqual(wire.resolution, { state: "unresolved", sampler_id: null, reason: "Resolved in this browser from the workflow.", reason_code: null, node: null }); + assert.deepEqual(wire.library, { status: "unavailable", scope: "saved-library", tags: [], category_scope: null, tags_scope: null }); + assert.equal(wire.metadata.status, "available"); + assert.deepEqual(wire.metadata.raw, { prompt: PROMPT, workflow: WORKFLOW }); + assert.deepEqual(wire.metadata.record_formats, { prompt: "original-text", workflow: "original-text" }); + assert.equal(wire.metadata.prompt_graph.status, "parsed"); + assert.equal(plain(wire.metadata.prompt_graph.value)["3"].inputs.cfg, "8.0"); + assert.equal(wire.metadata.workflow_graph.status, "parsed"); + assert.equal(wire.metadata.parameters, null); + for (const key of Object.keys(wire)) assert.ok(["success", "inspection_version", "file", "prompts", "resolution", "metadata", "library"].includes(key), key); + // The existing projection renders it: facts from the API graph, legacy fill for the roles, no library link. + assert.deepEqual(record.identity, { kind: "local-file", key: handle.key, path: "Fox Render.PNG" }); + assert.equal(record.file.observation, "selected-local-file"); + assert.equal(record.generation.seed.value, "7"); + assert.equal(record.generation.cfg.value, "8.0"); + assert.equal(record.prompts.positive.text, "a red fox in snow, detailed fur"); + assert.equal(record.prompts.positive.source, "graph-inferred"); + assert.equal(record.prompts.negative.text, "blurry, lowres"); + assert.equal(record.historyTarget, null); + assert.deepEqual(record.download, { url: "blob:http://host.test/11111111-2222-3333-4444-555555555555", filename: "Fox Render.PNG" }); + const downloads = inspectionDownloads(record); + assert.equal(downloads.metadata.available, true); + assert.equal(downloads.workflow.bodyText, WORKFLOW); + assert.equal(downloads.metadata.filename, "Fox Render.metadata.json"); + const exported = JSON.parse(downloads.metadata.bodyText); + assert.equal(exported.identity.kind, "local-file"); + assert.equal(exported.file.source_label, "Selected local file"); +}); + +test("file facts follow the server: extension like Path.suffix, null when unknown", async () => { + const cases = [["a.b.PnG", ".png"], [".hidden", ""], ["noext", ""], ["dot.", ""], ["x.tar.gz", ".gz"]]; + for (const [name, extension] of cases) { + const wire = m.buildLocalPayload({ read: { status: "complete", records: {}, limited: [], width: 2, height: 3 }, file: { name, size: 10, lastModified: 0 }, classification: { media: "image", kind: "png" } }); + assert.equal(wire.file.extension, extension, name); + assert.equal(wire.file.modified_time, 0); + } + const sparse = m.buildLocalPayload({ read: { status: "complete", records: {}, limited: [], width: 2, height: 3 }, file: { name: "a.png" }, classification: { media: "image", kind: "png" } }); + assert.deepEqual([sparse.file.size, sparse.file.modified_time], [null, null]); + assert.equal(m.buildLocalPayload({ read: { status: "complete", records: {}, limited: [], width: 2, height: 3 }, file: { name: "a.png", size: -1, lastModified: NaN }, classification: { media: "image", kind: "png" } }).file.size, null); + assert.throws(() => m.buildLocalPayload({ read: null, file: { name: "a" }, classification: null }), TypeError); + assert.throws(() => m.buildLocalPayload({ read: null, file: { name: "a" }, classification: { media: "text", kind: "svg" } }), TypeError); +}); + +test("sanitized filenames carry no separators or control characters", () => { + assert.equal(m.sanitizeLocalFilename("../a/b\\c\u0000\u001f\u007f.png"), ".._a_b_c.png"); + assert.equal(m.sanitizeLocalFilename(" spaced .png "), "spaced .png"); + assert.equal(m.sanitizeLocalFilename(""), "file"); + assert.equal(m.sanitizeLocalFilename(undefined), "file"); + assert.equal(m.sanitizeLocalFilename("\u0000"), "file"); + assert.equal([...m.sanitizeLocalFilename("😀".repeat(300))].length, 255); +}); + +test("unreadable PNG metadata is unreadable, not absent", async () => { + const { wire, record } = await recordFor(pngFile("bad.png", [tEXt("prompt", PROMPT, { crc: 0 })])); + assert.equal(wire.metadata.status, "unreadable"); + assert.deepEqual([wire.file.format, wire.file.width, wire.file.height], [null, null, null]); + assert.equal(record.generation.seed.reason, "metadata-unreadable"); + assert.equal(record.prompts.positive.status, "unresolved"); + assert.equal(JSON.parse(inspectionDownloads(record).metadata.bodyText).metadata.status, "unreadable"); + const limited = await recordFor(pngFile("big.png", [tEXt("prompt", PROMPT), tEXt("workflow", "[" + "0,".repeat(2_097_153) + "]")])); + assert.deepEqual(limited.wire.metadata.record_formats, { prompt: "original-text", workflow: "limited" }); + assert.equal(limited.wire.metadata.workflow_graph.status, "limited"); + assert.equal(limited.record.metadata.record_formats.workflow, "limited"); +}); + +test("JPEG and WebP previews have header facts and absent metadata", async () => { + for (const [name, format, width, height] of [["tiny.jpg", "JPEG", 48, 32], ["tiny.webp", "WEBP", 40, 24]]) { + const { wire, record } = await recordFor(fixtureFile(name)); + assert.deepEqual([wire.file.format, wire.file.width, wire.file.height, wire.file.media_type], [format, width, height, "image"], name); + assert.equal(wire.metadata.status, "absent"); + assert.equal(record.generation.seed.status, "not-recorded"); + } +}); + +test("video payloads", async () => { + const legacy = await recordFor(fixtureFile("legacy-comment.mp4")); + assert.deepEqual([legacy.wire.file.media_type, legacy.wire.file.format, legacy.wire.file.width, legacy.wire.file.extension], ["video", null, null, ".mp4"]); + assert.equal(legacy.wire.metadata.record_formats.workflow, "reserialized-json"); + assert.equal(legacy.wire.metadata.record_formats.prompt, "original-text"); + assert.equal(legacy.wire.metadata.workflow_graph.status, "parsed"); + assert.equal(inspectionDownloads(legacy.record).workflow.available, false); + assert.equal(inspectionDownloads(legacy.record).workflow.reason, CONTAINER_WORKFLOW); + assert.equal(legacy.record.prompts.positive.text, "a red fox in snow, detailed fur"); + // Ruling M1: a Matroska COMMENT tag is not the exact-case `comment` envelope, so it is absent, as Images reports it. + const webm = await recordFor(fixtureFile("legacy-comment.webm")); + assert.equal(webm.wire.metadata.status, "absent"); + const tags = await recordFor(fixtureFile("core-tags.webm")); + assert.deepEqual(tags.wire.metadata.record_formats, { prompt: "original-text", workflow: "original-text" }); + assert.equal(tags.record.generation.seed.value, "7"); + // AVI and ASF are recognised, previewable, and never read. + const avi = new File([utf8("RIFF"), new Uint8Array([0, 0, 0, 0]), utf8("AVI LIST"), new Uint8Array(64)], "clip.avi"); + const aviRecord = await recordFor(avi); + assert.equal(aviRecord.wire.metadata.status, "unreadable"); + assert.equal(aviRecord.wire.file.media_type, "video"); + assert.equal(aviRecord.record.generation.seed.reason, "metadata-unreadable"); + const asf = new File([new Uint8Array([0x30, 0x26, 0xb2, 0x75, 0x8e, 0x66, 0xcf, 0x11, 0xa6, 0xd9, 0x00, 0xaa, 0x00, 0x62, 0xce, 0x6c]), new Uint8Array(64)], "clip.wmv"); + assert.equal((await recordFor(asf)).wire.metadata.status, "unreadable"); + // A pre-classified video needs no PNG reader; an image without one is an error, not a silent absence. + const clip = fixtureFile("core.mkv"); + assert.equal((await m.readLocalFile(clip, { classification: { media: "video", kind: "matroska" }, pngMetadata: null })).result.read.status, "complete"); + await assert.rejects(m.readLocalFile(clip, { pngMetadata: null }), /PNG reader/); + await assert.rejects(m.readLocalFile(fixtureFile("tiny.jpg"), { classification: { media: "image", kind: "jpeg" }, pngMetadata: null }), /PNG reader/); + await assert.rejects(m.readLocalFile("not a blob"), TypeError); + assert.deepEqual(await m.readLocalFile(new File([utf8("")], "x.png"), { pngMetadata: PngMetadata }), { classification: null, result: null }); + // A container that fails to parse is unreadable too. + const broken = new File([utf8("\0\0\0\x08ftypisom"), utf8("\0\0\0\x01moov")], "broken.mp4"); + assert.equal((await recordFor(broken)).wire.metadata.status, "unreadable"); +}); + +test("local cap measures escaped UTF-8 atomically", async () => { + // A 1 MiB Latin-1 prompt of 'é' (2 UTF-8 bytes, 1 code unit each) and a 4 MiB UTF-8 workflow of + // emoji (4 bytes, 2 units): the UTF-16 length of the wire is well under the limit while its UTF-8 + // size is not, so a cap counting code units would keep too much. + const prompt = `{"1":{"class_type":"A","inputs":{"t":"${"é".repeat(1_000_000)}"}}}`; + const workflow = `{"nodes":[],"u":"${"😀".repeat(1_000_000)}"}`; + const file = pngFile("wide.png", [iTXt("workflow", workflow), tEXt("prompt", prompt)]); + const handle = await handleFor(file); + const raw = await m.readLocalFile(handle.file, { classification: handle.classification, pngMetadata: PngMetadata }); + assert.equal(raw.result.status, "complete"); + const wire = m.buildLocalPayload({ read: raw.result, file: { name: file.name, size: file.size, lastModified: file.lastModified }, classification: handle.classification }); + const text = JSON.stringify(wire); + assert.ok(text.length < LIMIT && bytes(wire) > LIMIT, `${text.length} code units, ${bytes(wire)} bytes`); + const fitted = m.fitLocalPayload(wire); + assert.ok(bytes(fitted) <= LIMIT); + assert.equal(fitted.metadata.workflow_graph.status, "limited", "the workflow graph goes first"); + assert.equal(fitted.metadata.prompt_graph.status, "parsed"); + assert.deepEqual(Object.keys(fitted.metadata.raw).sort(), ["prompt", "workflow"], "raw is kept"); + const record = m.localFileAdapter.toRecord(fitted, handle, extractGenerationFacts); + assert.equal(inspectionDownloads(record).metadata.available, true); + assert.equal(inspectionDownloads(record).workflow.available, false); + // Whole-raw omission is atomic and marks the response-size limit; the inspector offers no metadata download. + const squeezed = m.fitLocalPayload(wire, bytes(fitted) - 1); + assert.deepEqual([squeezed.metadata.workflow_graph.status, squeezed.metadata.prompt_graph.status], ["limited", "limited"]); + const tiny = m.fitLocalPayload(wire, 2000); + assert.equal(tiny.metadata.status, "limited"); + assert.equal(tiny.metadata.omitted_reason, "response-size-limit"); + assert.deepEqual(tiny.metadata.raw, {}); + assert.equal(tiny.metadata.parameters, null); + assert.ok(bytes(tiny) <= 2000); + const omitted = m.localFileAdapter.toRecord(tiny, handle, extractGenerationFacts); + assert.equal(inspectionDownloads(omitted).metadata.available, false); + assert.equal(omitted.generation.seed.reason, "projection-limited"); + assert.throws(() => m.fitLocalPayload(wire, 100), /exceeds/); + assert.equal(wire.metadata.status, "available", "the input wire is not mutated"); + assert.equal((await m.localFileAdapter.read(handle)).metadata.workflow_graph.status, "limited", "adapter.read returns the fitted wire"); +}); + +test("adapter rejects foreign blob URLs and never resolves owned ones", async () => { + const file = pngFile("a.png", [tEXt("prompt", PROMPT)]); + const classification = await classify(file); + const owned = m.createLocalFileHandle({ file, url: "blob:http://host.test/0b0e", classification }); + assert.ok(Object.isFrozen(owned)); + assert.deepEqual(m.localFileAdapter.originalDownload(owned), { url: "blob:http://host.test/0b0e", filename: "a.png" }); + assert.equal(m.localFileAdapter.mediaUrl(owned), "blob:http://host.test/0b0e"); + // The controller and the viewer session spread-copy descriptors: a copy is the same selection. + const copy = { ...owned, occurrence: {}, reference: {} }; + assert.equal(m.localFileAdapter.key(copy), owned.key); + assert.equal(m.localFileAdapter.mediaUrl(copy), "blob:http://host.test/0b0e"); + // A tampered URL, key, File or name, or a descriptor this module never made, is foreign. + const tampered = [{ ...owned, url: "blob:http://host.test/other" }, { ...owned, key: "local:1" }, { ...owned, file: new File([], "x.png") }, { ...owned, filename: "other.png" }, + { kind: "local-file", key: owned.key, filename: owned.filename, url: owned.url, file, is_video: false, classification }, null, "local:1"]; + for (const foreign of tampered) { + for (const method of ["validate", "originalDownload", "mediaUrl", "key", "scope", "inspectionPath"]) assert.throws(() => m.localFileAdapter[method](foreign), TypeError, method); + await assert.rejects(m.localFileAdapter.read(foreign), TypeError); + assert.throws(() => m.localFileAdapter.toRecord({ success: true, inspection_version: 1 }, foreign, extractGenerationFacts), TypeError); + } + for (const url of ["http://host.test/x.png", "/prompt_manager/images/serve/a.png", "javascript:alert(1)", "data:image/png;base64,AA==", "", 5]) { + assert.throws(() => m.createLocalFileHandle({ file, url, classification }), TypeError, String(url)); + } + assert.throws(() => m.createLocalFileHandle({ file: "not a blob", url: "blob:http://host.test/1", classification }), TypeError); + assert.throws(() => m.createLocalFileHandle({ file, url: "blob:http://host.test/1", classification: null }), TypeError); + assert.throws(() => m.createLocalFileHandle({ file, url: "blob:http://host.test/1", classification: { media: "text", kind: "svg" } }), TypeError); + // A proxy-mounted host resolves API URLs; blob URLs are session-owned and bypass it. + const before = Object.getOwnPropertyDescriptor(globalThis, "window"); + Object.defineProperty(globalThis, "window", { configurable: true, value: { location: { href: "https://example.test/proxy/prompt_manager/workspace" }, ApiClient: { resolveUrl: (url) => `/proxy${url}` } } }); + try { + assert.equal(m.localFileAdapter.mediaUrl(owned), "blob:http://host.test/0b0e"); + assert.equal(m.localFileAdapter.originalDownload(owned).url, "blob:http://host.test/0b0e"); + } finally { + if (before) Object.defineProperty(globalThis, "window", before); else delete globalThis.window; + } + // Without an object URL the record has no download and the viewer cannot show the original. + const previewless = m.createLocalFileHandle({ file, url: null, classification }); + assert.equal(m.localFileAdapter.mediaUrl(previewless), null); + assert.throws(() => m.localFileAdapter.originalDownload(previewless), TypeError); + const record = m.localFileAdapter.toRecord(await m.localFileAdapter.read(previewless), previewless, extractGenerationFacts); + assert.equal(record.download, null); + assert.equal(inspectionDownloads(record).metadata.filename, "a.metadata.json"); +}); + +test("select reuses the same local key", async () => { + const file = pngFile("a.png", [tEXt("prompt", PROMPT)]); + const classification = await classify(file); + const first = m.createLocalFileHandle({ file, url: "blob:http://host.test/1", classification }); + const second = m.createLocalFileHandle({ file, url: "blob:http://host.test/2", classification }); + assert.notEqual(first.key, second.key, "the same bytes chosen twice are two selections"); + assert.equal(m.localFileAdapter.scope(first), "local"); + assert.equal(m.localFileAdapter.inspectionPath(first), `local-file:${first.key}`); + assert.equal(m.localFileAdapter.key(first), first.key); + const reads = []; + // Until the loader seam lands (PR L2 Task 4) the controller reaches the adapter through api.get. + const api = { get: (sentinel) => { reads.push(sentinel); return m.localFileAdapter.read(reads.length === 1 || sentinel === `local-file:${first.key}` ? first : second); } }; + const controller = createInspectionController({ api, extractFacts: extractGenerationFacts, adapter: m.localFileAdapter }); + const pending = controller.select(first); + assert.equal(controller.select(first), pending, "the same handle returns the pending read"); + await pending; + assert.deepEqual(reads, [`local-file:${first.key}`]); + assert.equal(controller.state.record.identity.key, first.key); + assert.equal(controller.state.record.prompts.positive.text, "a red fox in snow, detailed fur"); + await controller.select(second); + assert.equal(reads.length, 2); + assert.equal(controller.state.record.identity.key, second.key); + await controller.select({ ...second, occurrence: {}, reference: {} }); + assert.equal(reads.length, 2, "the controller's own copy is the same selection"); + await controller.select({ ...second, url: "blob:http://host.test/evil" }); + assert.equal(controller.state.error, true, "a foreign handle is published as an error, never read"); + assert.equal(reads.length, 2); + controller.dispose(); +}); + +test("fileFor admits selected-local-file only for a local identity", async () => { + const file = pngFile("a.png", [tEXt("prompt", PROMPT)]); + const { wire, handle } = await recordFor(file); + const rooted = { root_index: 0, path: "folder/a.png", filename: "a.png", url: "/prompt_manager/images/serve/folder/a.png?root=0" }; + assert.throws(() => toInspectionRecord({ ...wire, image: { root_index: 0, path: "folder/a.png", filename: "a.png" } }, rooted, extractGenerationFacts), /Invalid inspection file/); + assert.throws(() => projectInspectionPayload({ ...wire, file: { ...wire.file, observation: "current-file" } }, { kind: "local-file", key: handle.key, path: "a.png" }, null, extractGenerationFacts), /Invalid inspection file/); + assert.throws(() => m.localFileAdapter.toRecord({ ...wire, success: false }, handle, extractGenerationFacts), TypeError); + assert.throws(() => m.localFileAdapter.toRecord({ ...wire, inspection_version: 2 }, handle, extractGenerationFacts), TypeError); + assert.throws(() => m.localFileAdapter.toRecord(null, handle, extractGenerationFacts), TypeError); +}); + +test("the parity corpus PNGs read into renderable records", async () => { + for (const name of ["f02.png", "f06a.png", "f23.png"]) { + const { wire, record } = await recordFor(fixtureFile(name, PARITY)); + assert.equal(wire.metadata.status, "available", name); + assert.equal(record.file.format, "PNG"); + assert.ok(["parsed", "limited", "malformed"].includes(wire.metadata.prompt_graph.status)); + } +}); diff --git a/tests/js/workspace-local-projection.test.js b/tests/js/workspace-local-projection.test.js new file mode 100644 index 00000000..4954ff43 --- /dev/null +++ b/tests/js/workspace-local-projection.test.js @@ -0,0 +1,234 @@ +// web/js/workspace/local-projection.mjs ports utils/inspection_response.py: the +// strict Decimal/duplicate-key parse, the prompt/workflow/carrier projection, the +// depth and node ceilings, _metadata and the 8 MiB omission steps. These units pin +// the rules the plan names; tests/test_local_inspect_parity.py proves byte parity +// with the server over the committed corpus. +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const m = await import("../../web/js/workspace/local-projection.mjs").catch((error) => { + if (error.code !== "ERR_MODULE_NOT_FOUND") throw error; + return {}; +}); +const CASES = JSON.parse(fs.readFileSync(path.join(here, "..", "fixtures", "local-inspect", "projection-cases.json"), "utf8")).cases; + +const plain = (value) => JSON.parse(JSON.stringify(value)); +const bytes = (value) => Buffer.byteLength(JSON.stringify(value), "utf8"); +const graphOf = (text, kind = "workflow") => plain(m.projectGraph(text, kind)); +const nested = (depth, close = "]", open = "[") => open.repeat(depth) + close.repeat(depth); +const nodes = (count) => `{${Array.from({ length: count }, (_, i) => `"${i}":{"class_type":"A","inputs":{}}`).join(",")}}`; + +test("contract", () => { + for (const name of ["projectGraph", "projectMetadata", "fitInspectionPayload", "inspectionBytes", "parseStrictJson"]) { + assert.equal(typeof m[name], "function", `${name} is exported`); + } + assert.equal(m.MAX_RESPONSE_BYTES, 8388608); + assert.equal(m.MAX_PROJECTION_DEPTH, 64); + assert.equal(m.GRAPH_NODE_CEILING, 20000); +}); + +test("projection spells numbers like the server", () => { + // literal -> Python str(Decimal(literal)) (or the int when it is a safe integer) + const table = [ + ["1e3", "1E+3"], ["1e0", "1"], ["-0.0", "-0.0"], ["6.50", "6.50"], ["0.0100", "0.0100"], ["1.0e2", "1.0E+2"], + ["0e5", "0E+5"], ["0.0000001", "1E-7"], ["0.000001", "0.000001"], ["1E+3", "1E+3"], ["1e-0", "1"], ["0.5e1", "5"], + ["0.05e1", "0.5"], ["0.0e0", "0.0"], ["1.5e+300", "1.5E+300"], ["123e-8", "0.00000123"], ["2.5e-3", "0.0025"], + ["1.0", "1.0"], ["-1.50", "-1.50"], ["0.0", "0.0"], ["100.0", "100.0"], ["1234567e-6", "1.234567"], ["0.5E-6", "5E-7"], + ["0.5E-5", "0.000005"], ["1e22", "1E+22"], ["0.000000", "0.000000"], ["-0.000000e-3", "-0E-9"], ["7.0e-6", "0.0000070"], + ["7.0e-7", "7.0E-7"], ["12345678901234567890.123", "12345678901234567890.123"], ["9007199254740992.0", "9007199254740992.0"], + ["1e999999999999999999", "1E+999999999999999999"], ["1e-1999999999999999997", "1E-1999999999999999997"], + ["0.0e1000000000000000000", "0E+999999999999999999"], ["1.5e999999999999999999", "1.5E+999999999999999999"], + ["0.1e-1999999999999999996", "1E-1999999999999999997"], + ["9007199254740991", 9007199254740991], ["-9007199254740991", -9007199254740991], ["9007199254740992", "9007199254740992"], + ["-9007199254740992", "-9007199254740992"], ["10000000000000000", "10000000000000000"], ["123456789012345678901234567890", "123456789012345678901234567890"], + ["0", 0], ["-0", 0], ["42", 42], ["-7", -7], + ]; + for (const [literal, expected] of table) { + const graph = graphOf(`{"n":${literal}}`); + assert.equal(graph.status, "parsed", literal); + assert.deepEqual(graph.value, { n: expected }, literal); + } + assert.deepEqual(graphOf('{"n":[NaN,Infinity,-Infinity]}').value, { n: [null, null, null] }); + // A 4300-digit integer is Python's int() limit; one more digit fails the parse. + assert.deepEqual(graphOf(`{"n":${"1".repeat(4300)}}`).value, { n: "1".repeat(4300) }); + assert.equal(graphOf(`{"n":${"1".repeat(4301)}}`).status, "malformed"); + assert.equal(graphOf(`{"n":-${"1".repeat(4301)}}`).status, "malformed"); + // Exact Decimal construction fails past the C library's exponent range. + for (const literal of ["1e1000000000000000000", "15e999999999999999999", "1e-1999999999999999998", "1.5e-1999999999999999997", + "0e1000000000000000000", "1e9223372036854775808", "0.0e1000000000000000001", "1e99999999999999999999999999"]) { + assert.equal(graphOf(`{"n":${literal}}`).status, "malformed", literal); + } + // Escapes: surrogate pairs join, lone surrogates stay, control characters decode. + const text = graphOf('{"s":"\\u00E9\\uD83D\\uDE00\\uD800\\u0000\\u001f\\/\\\\\\"\\b\\f\\n\\r\\t\\uD800\\u0041"}').value.s; + assert.equal(text, "é\u{1f600}\ud800\u0000\u001f/\\\"\b\f\n\r\t\ud800A"); + assert.equal(JSON.stringify(m.projectGraph('{"s":"\\ud800"}', "workflow").value), '{"s":"\\ud800"}'); +}); + +test("projection rejects what Python's json rejects and nothing else", () => { + const rejected = ["{}", "\f1", "  1", '{"a":1,}', "[1,]", "{'a':1}", '{"a"}', "[1 2]", "tru", "nul", "-NaN", "1.", ".5", "01", "+1", + "1e", "-", '"\\x"', '"abc', "", " ", '"\u0001"', '{"a":1}x', '"\\u12"', '{"a":1,"a":2}', '{"a":{"b":1,"b":1}}', "[1,[2,{\"k\":1,\"k\":2}]]", + '{"a":1 "b":2}', "[,1]", "{,}", '{"a":}', "[1}", "{1:2}", "
[]", "[]\u000b", "0x10", "1_000", "--1", "1e+", "1E-", '"\\uD800\\uDC"']; + for (const text of rejected) assert.equal(graphOf(text).status, "malformed", JSON.stringify(text)); + for (const text of [" \t\r\n{}\n\t ", '{"a":[1,{"b":null}],"c":"d"}', '{"\\u0000":1}', '{"a":1,"A":1}', '{"":[]}']) { + assert.equal(graphOf(text).status, "parsed", JSON.stringify(text)); + } + // Top-level values that are not objects parse but are not graphs. + for (const text of ["[]", '"x"', "1", "null", "true", "NaN", "1.5", "-Infinity"]) assert.equal(graphOf(text).status, "malformed", text); + assert.equal(graphOf(undefined).status, "malformed"); + assert.equal(graphOf(null).status, "malformed"); +}); + +test("projection limits", () => { + // A workflow's root is depth 1; the 64th nested container is kept, the 65th is limited. + assert.equal(graphOf(`{"a":${nested(63)}}`).status, "parsed"); + assert.equal(graphOf(`{"a":${nested(64)}}`).status, "limited"); + // Prompt: root 1, node 2, inputs 3; a value chain reaching depth 64 is kept, 65 is limited. + assert.equal(graphOf(`{"1":{"class_type":"A","inputs":{"v":${nested(61)}}}}`, "prompt").status, "parsed"); + assert.equal(graphOf(`{"1":{"class_type":"A","inputs":{"v":${nested(62)}}}}`, "prompt").status, "limited"); + // Dropped node keys are still validated for depth. + assert.equal(graphOf(`{"1":{"class_type":"A","inputs":{},"_meta":{"v":${nested(61)}}}}`, "prompt").status, "parsed"); + assert.equal(graphOf(`{"1":{"class_type":"A","inputs":{},"_meta":{"v":${nested(62)}}}}`, "prompt").status, "limited"); + // A workflow is normalized before its shape is checked, so a deep non-object is limited; a + // prompt checks its root first. Depth never overflows the parser (100,000 nested arrays). + assert.equal(graphOf(nested(10)).status, "malformed"); + assert.equal(graphOf(nested(5000)).status, "limited"); + assert.equal(graphOf(nested(5000), "prompt").status, "malformed"); + assert.equal(graphOf(`{"a":${nested(100000)}}`).status, "limited"); + // Node ceilings: 20,000 API nodes parse, 20,001 are limited. + assert.equal(graphOf(nodes(20000), "prompt").status, "parsed"); + assert.equal(graphOf(nodes(20001), "prompt").status, "limited"); + const list = (count) => `[${Array.from({ length: count }, () => "{}").join(",")}]`; + assert.equal(graphOf(`{"nodes":${list(20000)}}`).status, "parsed"); + assert.equal(graphOf(`{"nodes":${list(20001)}}`).status, "limited"); + assert.equal(graphOf(`{"nodes":${list(10001)},"definitions":{"subgraphs":[{"nodes":${list(10000)}},5,{"nodes":3}]}}`).status, "limited"); + assert.equal(graphOf(`{"nodes":${list(10000)},"definitions":{"subgraphs":[{"nodes":${list(10000)}}]}}`).status, "parsed"); + assert.equal(graphOf(`{"nodes":{"${"x".repeat(5)}":1},"definitions":[]}`).status, "parsed"); + // Duplicate keys anywhere are malformed, including inside dropped node keys. + assert.equal(graphOf('{"1":{"class_type":"A","inputs":{},"_meta":{"t":1,"t":2}}}', "prompt").status, "malformed"); + assert.equal(graphOf('{"1":{"class_type":"A","inputs":{"a":1,"a":1}}}', "prompt").status, "malformed"); +}); + +test("prompt nodes keep only class_type and inputs on null-prototype objects", () => { + const text = '{"3":{"class_type":"KSampler","inputs":{"seed":9007199254740993,"cfg":8.0,"n":NaN},"_meta":{"title":"x"},"is_changed":[1]},"4":5,"5":[1,2.50],"__proto__":{"class_type":"P","inputs":{}},"constructor":"c"}'; + const graph = m.projectGraph(text, "prompt"); + assert.equal(graph.status, "parsed"); + assert.equal(Object.getPrototypeOf(graph.value), null); + assert.equal(Object.getPrototypeOf(graph.value["3"]), null); + assert.equal(Object.getPrototypeOf(graph.value["3"].inputs), null); + // JSON.parse, like the projection, makes "__proto__" an own key; an object literal would not. + assert.deepEqual(plain(graph.value), JSON.parse('{"3":{"class_type":"KSampler","inputs":{"seed":"9007199254740993","cfg":"8.0","n":null}},"4":5,"5":[1,"2.50"],"__proto__":{"class_type":"P","inputs":{}},"constructor":"c"}')); + assert.ok(Object.hasOwn(graph.value, "__proto__")); + assert.deepEqual(plain(graph.value["__proto__"]), { class_type: "P", inputs: {} }); + assert.equal(graph.value.constructor, "c"); + for (const text of ["[]", '"3"', "5", "null", '{"nodes":[]}x']) assert.equal(m.projectGraph(text, "prompt").status, text === '{"nodes":[]}x' ? "malformed" : "malformed", text); + // A workflow keeps everything, normalized. + assert.deepEqual(graphOf('{"nodes":[{"id":1,"widgets_values":[7,"fixed",8.0,1e3]}],"links":[],"version":0.4}').value, + { nodes: [{ id: 1, widgets_values: [7, "fixed", "8.0", "1E+3"] }], links: [], version: "0.4" }); +}); + +test("projectMetadata mirrors _metadata for records, formats and carriers", () => { + const prompt = '{"1":{"class_type":"A","inputs":{"x":1.0}}}', workflow = '{"nodes":[],"version":0.4}'; + const read = (records, record_formats, carrierText = null, limited = []) => ({ records, record_formats, carrierText, limited }); + assert.deepEqual(plain(m.projectMetadata(null)), { status: "unreadable", omitted_reason: null, raw: {}, record_formats: {}, parameters: null, + prompt_graph: { status: "absent", value: null }, workflow_graph: { status: "absent", value: null } }); + assert.deepEqual(plain(m.projectMetadata(read({}, {}))), { status: "absent", omitted_reason: null, raw: {}, record_formats: {}, parameters: null, + prompt_graph: { status: "absent", value: null }, workflow_graph: { status: "absent", value: null } }); + const full = plain(m.projectMetadata(read({ prompt, workflow, parameters: "cat\nSteps: 1", comfyassets_prompt_pairs: "{}", other: "x" }, + { prompt: "original-text", workflow: "original-text", parameters: "original-text", comfyassets_prompt_pairs: "original-text", other: "original-text" }))); + assert.deepEqual(full, { status: "available", omitted_reason: null, + raw: { prompt, workflow, parameters: "cat\nSteps: 1", comfyassets_prompt_pairs: "{}" }, + record_formats: { prompt: "original-text", workflow: "original-text", parameters: "original-text", comfyassets_prompt_pairs: "original-text" }, + parameters: "cat\nSteps: 1", prompt_graph: { status: "parsed", value: { 1: { class_type: "A", inputs: { x: "1.0" } } } }, + workflow_graph: { status: "parsed", value: { nodes: [], version: "0.4" } } }); + // limited records lose their text; invalid is malformed; reserialized without a carrier is limited. + const limited = plain(m.projectMetadata(read({ prompt, workflow }, { prompt: "original-text" }, null, ["workflow"]))); + assert.deepEqual(limited.raw, { prompt }); + assert.deepEqual(limited.record_formats, { prompt: "original-text", workflow: "limited" }); + assert.equal(limited.workflow_graph.status, "limited"); + assert.equal(plain(m.projectMetadata(read({ prompt }, { prompt: "original-text", workflow: "invalid" }))).workflow_graph.status, "malformed"); + assert.equal(plain(m.projectMetadata(read({ workflow }, { workflow: "reserialized-json" }))).workflow_graph.status, "limited"); + assert.equal(plain(m.projectMetadata(read({ prompt: "{" }, { prompt: "original-text" }))).prompt_graph.status, "malformed"); + // Unknown formats are dropped from record_formats but the raw text still projects. + const odd = plain(m.projectMetadata(read({ prompt }, { prompt: "weird" }))); + assert.deepEqual(odd.record_formats, {}); + assert.equal(odd.prompt_graph.status, "parsed"); + // A non-text record vanishes from raw and its named graph is malformed. + assert.equal(plain(m.projectMetadata(read({ prompt: 5 }, { prompt: "original-text" }))).prompt_graph.status, "malformed"); + // Carriers: strings re-parse by kind, objects were projected at depth 2; a bad carrier fails every present graph. + const envelope = JSON.stringify({ prompt, workflow: { nodes: [], v: 1 } }); + const carried = plain(m.projectMetadata(read({ prompt, workflow: '{"nodes": [], "v": 1}' }, { prompt: "original-text", workflow: "reserialized-json" }, envelope))); + assert.deepEqual(carried.prompt_graph, { status: "parsed", value: { 1: { class_type: "A", inputs: { x: "1.0" } } } }); + assert.deepEqual(carried.workflow_graph, { status: "parsed", value: { nodes: [], v: 1 } }); + const broken = plain(m.projectMetadata(read({ prompt, workflow }, { prompt: "original-text", workflow: "original-text" }, "[1]"))); + assert.deepEqual([broken.prompt_graph.status, broken.workflow_graph.status], ["malformed", "malformed"]); + const deep = plain(m.projectMetadata(read({ prompt }, { prompt: "original-text" }, `{"title":${nested(70)},"prompt":${JSON.stringify(prompt)}}`))); + assert.equal(deep.prompt_graph.status, "limited"); + const partial = plain(m.projectMetadata(read({ prompt, workflow }, { prompt: "original-text", workflow: "original-text" }, '{"prompt":"{","workflow":5}'))); + assert.deepEqual([partial.prompt_graph.status, partial.workflow_graph.status], ["malformed", "malformed"]); + const missing = plain(m.projectMetadata(read({ prompt }, { prompt: "original-text" }, '{"workflow":{}}'))); + assert.deepEqual([missing.prompt_graph.status, missing.workflow_graph.status], ["malformed", "absent"]); + // The carrier's own prompt object is projected: depth 2, node keys filtered. + const objectPrompt = plain(m.projectMetadata(read({ prompt: "x" }, { prompt: "reserialized-json" }, '{"prompt":{"1":{"class_type":"A","inputs":{},"_meta":{}}}}'))); + assert.deepEqual(objectPrompt.prompt_graph, { status: "parsed", value: { 1: { class_type: "A", inputs: {} } } }); +}); + +test("fitInspectionPayload omits the workflow graph, then the prompt graph, then all raw atomically", () => { + const payload = { + success: true, inspection_version: 1, metadata: { + status: "available", omitted_reason: null, raw: { prompt: "é".repeat(100), workflow: "{}" }, record_formats: { prompt: "original-text", workflow: "original-text" }, + parameters: null, prompt_graph: { status: "parsed", value: { 1: { class_type: "A", inputs: { t: "\ud800".repeat(50) } } } }, + workflow_graph: { status: "parsed", value: { nodes: Array.from({ length: 50 }, (_, i) => ({ id: i })) } }, + }, + }; + const text = JSON.stringify(payload); + // UTF-8 bytes, not UTF-16 code units: 'é' is 2 bytes, an escaped lone surrogate 6. + assert.equal(m.inspectionBytes(payload), Buffer.byteLength(text, "utf8")); + assert.ok(m.inspectionBytes(payload) > text.length); + const whole = m.fitInspectionPayload(payload, bytes(payload)); + assert.equal(whole, payload, "a payload within the limit is returned as is"); + const first = m.fitInspectionPayload(payload, bytes(payload) - 1); + assert.equal(first.metadata.workflow_graph.status, "limited"); + assert.equal(first.metadata.prompt_graph.status, "parsed"); + assert.deepEqual(first.metadata.raw, payload.metadata.raw); + assert.ok(bytes(first) <= bytes(payload) - 1); + const second = m.fitInspectionPayload(payload, bytes(first) - 1); + assert.deepEqual([second.metadata.workflow_graph.status, second.metadata.prompt_graph.status], ["limited", "limited"]); + assert.equal(second.metadata.status, "available"); + assert.deepEqual(second.metadata.raw, payload.metadata.raw); + const third = m.fitInspectionPayload(payload, bytes(second) - 1); + assert.deepEqual(third.metadata, { ...second.metadata, raw: {}, parameters: null, status: "limited", omitted_reason: "response-size-limit" }); + assert.ok(bytes(third) <= bytes(second) - 1); + assert.throws(() => m.fitInspectionPayload(payload, bytes(third) - 1), /exceeds/); + // The caller's payload is never mutated and absent graphs are left alone. + assert.equal(JSON.stringify(payload), text); + const absent = { ...payload, metadata: { ...payload.metadata, workflow_graph: { status: "absent", value: null } } }; + const fitted = m.fitInspectionPayload(absent, bytes(absent) - 1); + assert.equal(fitted.metadata.workflow_graph.status, "absent"); + assert.equal(fitted.metadata.prompt_graph.status, "limited"); + assert.equal(m.fitInspectionPayload(payload).metadata.status, "available", "the default limit is 8 MiB"); +}); + +test("the committed projection corpus has every outcome and parses with the oracle entry shape", () => { + assert.ok(CASES.length >= 40); + const names = new Set(), statuses = new Set(), graphs = new Set(); + for (const entry of CASES) { + assert.equal(typeof entry.name, "string"); + assert.ok(!names.has(entry.name), `duplicate case ${entry.name}`); + names.add(entry.name); + const read = entry.records === null ? null : { records: entry.records, record_formats: entry.record_formats ?? Object.fromEntries(Object.keys(entry.records).map((key) => [key, "original-text"])), + carrierText: entry.carrier_text ?? null, limited: entry.limited ?? [] }; + const metadata = m.projectMetadata(read); + statuses.add(metadata.status); + graphs.add(metadata.prompt_graph.status); + graphs.add(metadata.workflow_graph.status); + if (entry.expect) { + for (const [field, status] of Object.entries(entry.expect)) assert.equal(metadata[field].status, status, `${entry.name} ${field}`); + } + } + assert.deepEqual([...statuses].sort(), ["absent", "available", "unreadable"]); + assert.deepEqual([...graphs].sort(), ["absent", "limited", "malformed", "parsed"]); +}); diff --git a/tests/test_local_inspect_parity.py b/tests/test_local_inspect_parity.py index f4b4db9a..51804ebe 100644 --- a/tests/test_local_inspect_parity.py +++ b/tests/test_local_inspect_parity.py @@ -16,6 +16,14 @@ webm}`` with their recorded ffprobe tag maps in ``video-tags-v1.json``), recorde and synthetic tag maps, and multi-megabyte per-key containers built at test time, and assert that the local carrier equals the server's, which selects from the same tag map through a mocked probe, or from the same file through real ffprobe. + +``web/js/workspace/local-projection.mjs`` ports ``utils/inspection_response.py``: +the strict Decimal/duplicate-key parse, the prompt, workflow and carrier +projection, the depth and node ceilings and ``_metadata``. The projection tests +hand the oracle every file above plus the committed adversarial record sets in +``tests/fixtures/local-inspect/projection-cases.json`` and node-ceiling sets built +at test time, and assert that ``projectMetadata`` equals ``_metadata`` byte for +byte, as the server's ``_bytes`` writes it with sorted keys. """ import io @@ -24,6 +32,7 @@ import os import shutil import struct import subprocess # nosec B404 +import sys import tempfile import unittest import zlib @@ -33,9 +42,11 @@ from unittest.mock import patch from PIL import Image from PIL.PngImagePlugin import PngInfo +from utils.inspection_response import GRAPH_NODE_CEILING, _bytes, _metadata from utils.prompt_inspector import ( ROLE_METADATA_BYTES, WORKFLOW_METADATA_BYTES, + InspectionRead, MetadataReadError, read_inspection_file, ) @@ -56,6 +67,9 @@ VIDEO_FIXTURES = HEADER_FIXTURES RECORDED_TAGS = json.loads( (VIDEO_FIXTURES / "video-tags-v1.json").read_text(encoding="utf-8") )["fixtures"] +PROJECTION_CASES = json.loads( + (HEADER_FIXTURES / "projection-cases.json").read_text(encoding="utf-8") +)["cases"] NODE_TIMEOUT_SECONDS = 180 PNG_CLASS = {"media": "image", "kind": "png"} _FOOTER = "Steps: 20, Sampler: Euler, Batch index: {}, Seed: 7" @@ -690,5 +704,238 @@ class LocalVideoCarrierParityTests(unittest.TestCase): read_inspection_file(files["roles_over_budget"]) +def _canonical(value): + """``value`` with every dict's keys sorted, so two orders compare as one.""" + if isinstance(value, dict): + return {key: _canonical(value[key]) for key in sorted(value)} + if isinstance(value, list): + return [_canonical(item) for item in value] + return value + + +def _canonical_bytes(value): + return _bytes(_canonical(value)) + + +def _server_metadata(path): + try: + return _metadata(read_inspection_file(path)) + except MetadataReadError: + return _metadata(None) + + +def _synthetic_read(case): + if case["records"] is None: + return None + formats = case.get("record_formats") + if formats is None: + formats = {key: "original-text" for key in case["records"]} + return InspectionRead( + case["records"], + formats, + case.get("carrier_text"), + None, + None, + None, + frozenset(case.get("limited", ())), + ) + + +def _case_supported(case): + """Whether this interpreter has the feature a case depends on.""" + if case.get("needs") == "int-max-str-digits": + limit = getattr(sys, "get_int_max_str_digits", None) + return limit is not None and limit() == 4300 + return True + + +def _node_ceiling_cases(): + """Record sets at and past the 20,000-node ceiling, too large to commit.""" + api = lambda count: "{%s}" % ",".join( # noqa: E731 + '"%d":{"class_type":"A","inputs":{}}' % index for index in range(count) + ) + ui = lambda count: "[%s]" % ",".join("{}" for _ in range(count)) # noqa: E731 + half = GRAPH_NODE_CEILING // 2 + return [ + {"name": "prompt_at_ceiling", "records": {"prompt": api(GRAPH_NODE_CEILING)}}, + { + "name": "prompt_over_ceiling", + "records": {"prompt": api(GRAPH_NODE_CEILING + 1)}, + }, + { + "name": "workflow_at_ceiling", + "records": {"workflow": '{"nodes":%s}' % ui(GRAPH_NODE_CEILING)}, + }, + { + "name": "workflow_over_ceiling", + "records": {"workflow": '{"nodes":%s}' % ui(GRAPH_NODE_CEILING + 1)}, + }, + { + "name": "workflow_subgraphs_over_ceiling", + "records": { + "workflow": '{"nodes":%s,"definitions":{"subgraphs":[{"nodes":%s},' + '5,{"nodes":3}]}}' % (ui(half + 1), ui(half)) + }, + }, + { + "name": "workflow_subgraphs_at_ceiling", + "records": { + "workflow": '{"nodes":%s,"definitions":{"subgraphs":[{"nodes":%s}]}}' + % (ui(half), ui(half)) + }, + }, + { + "name": "carrier_prompt_over_ceiling", + "records": {"prompt": "x"}, + "record_formats": {"prompt": "reserialized-json"}, + "carrier_text": '{"prompt":%s}' % api(GRAPH_NODE_CEILING + 1), + }, + ] + + +class LocalProjectionParityTests(unittest.TestCase): + """The local projection answers byte for byte as ``_metadata`` does.""" + + def setUp(self): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.addCleanup(assert_closed, self, temporary.name) + self.directory = Path(temporary.name) + + def _files(self): + files = {} + for name in parity.COMMITTED: + files[name] = parity.fixture_path(name) + for name in parity.TEST_TIME: + path = parity.fixture_path(name, self.directory) + parity.write_png(path, parity.build(name)) + files[name] = path + for name, write in EXTRA_SHAPES.items(): + path = self.directory / f"{name}.png" + write(path) + files[name] = path + for header in ("tiny.jpg", "tiny.webp"): + files[header] = HEADER_FIXTURES / header + for name in RECORDED_TAGS: + files[name] = VIDEO_FIXTURES / name + return files + + def test_local_metadata_matches_route_for_parity_corpus(self): + files = self._files() + cases = [ + case + for case in PROJECTION_CASES + _node_ceiling_cases() + if _case_supported(case) + ] + self.assertGreater(len(cases), len(PROJECTION_CASES) - 3) + entries = [{"name": name, "path": str(path)} for name, path in files.items()] + entries += [ + { + "name": "case:" + case["name"], + "records": case["records"], + "record_formats": case.get("record_formats"), + "carrier_text": case.get("carrier_text"), + "limited": case.get("limited", []), + } + for case in cases + ] + observed = _run_oracle(self, entries) + outcomes = set() + for name, path in files.items(): + with self.subTest(fixture=name): + self.assertIsNotNone(observed[name]["classified"], name) + if path.suffix in VIDEO_CONTAINERS and not HAVE_FFMPEG: + # Without ffprobe the server cannot read the file; the tag-map + # vectors below cover the carrier projection instead. + continue + server = _server_metadata(path) + local = observed[name]["metadata"] + self.assertEqual(_canonical_bytes(local), _canonical_bytes(server)) + outcomes.add(server["status"]) + for field in ("prompt_graph", "workflow_graph"): + outcomes.add(field + ":" + server[field]["status"]) + for case in cases: + with self.subTest(case=case["name"]): + server = _metadata(_synthetic_read(case)) + local = observed["case:" + case["name"]]["metadata"] + self.assertEqual(_canonical_bytes(local), _canonical_bytes(server)) + expect = case.get("expect") or {} + for field, status in expect.items(): + self.assertEqual(server[field]["status"], status, field) + outcomes.add(server["status"]) + for field in ("prompt_graph", "workflow_graph"): + outcomes.add(field + ":" + server[field]["status"]) + # Every projection outcome must occur, or agreement proves little. + self.assertEqual( + outcomes, + { + "available", + "absent", + "unreadable", + "prompt_graph:parsed", + "prompt_graph:limited", + "prompt_graph:malformed", + "prompt_graph:absent", + "workflow_graph:parsed", + "workflow_graph:limited", + "workflow_graph:malformed", + "workflow_graph:absent", + }, + ) + + def test_recorded_video_tag_maps_project_as_the_server(self): + """The carrier projection on the recorded maps, with or without ffprobe.""" + vectors = [(name, tags, []) for name, tags in RECORDED_TAGS.items()] + vectors += [ + (name, tags, limited) + for name, tags, limited in TAG_MAP_VECTORS + if name + in { + "per_key_wins_over_comment", + "object_prompt_reserialized", + "python_number_spellings", + "integer_like_keys_keep_order", + "invalid_envelope_workflow_keeps_roles", + "workflow_only_invalid_envelope", + "reader_limited_workflow_with_per_key", + "envelope_workflow_over_budget_limited", + } + ] + observed = _run_oracle( + self, + [ + {"name": name, "tags": tags, "limited": limited} + for name, tags, limited in vectors + ], + ) + seen = set() + for name, tags, limited in vectors: + with self.subTest(vector=name): + with ( + patch( + "utils.prompt_inspector.ffprobe_path", + return_value="/tool/ffprobe", + ), + patch( + "utils.prompt_inspector._probe_video_tags", + side_effect=_fake_probe( + tags, drop_workflow="workflow" in limited + ), + ), + ): + try: + server = _metadata(read_inspection_file(Path("vector.mp4"))) + except MetadataReadError: + server = _metadata(None) + local = observed[name]["metadata"] + self.assertEqual(_canonical_bytes(local), _canonical_bytes(server)) + seen.update(server["record_formats"].values()) + seen.add(server["workflow_graph"]["status"]) + self.assertTrue( + {"reserialized-json", "original-text", "invalid", "limited"} <= seen + ) + self.assertTrue({"parsed", "malformed", "limited"} <= seen) + + if __name__ == "__main__": unittest.main() diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index 25b3a65a..6160a621 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -293,6 +293,18 @@ "functions": 96, "statements": 96 }, + "workspace/local-inspection.mjs": { + "lines": 98, + "branches": 85, + "functions": 98, + "statements": 98 + }, + "workspace/local-projection.mjs": { + "lines": 97, + "branches": 95, + "functions": 98, + "statements": 97 + }, "workspace/local-video.mjs": { "lines": 98, "branches": 93, diff --git a/web/js/workspace/inspection-state.mjs b/web/js/workspace/inspection-state.mjs index 1a2fdbe2..bfb61edf 100644 --- a/web/js/workspace/inspection-state.mjs +++ b/web/js/workspace/inspection-state.mjs @@ -87,8 +87,10 @@ function promptsFor(value) { return value; } -function fileFor(value) { - if (!object(value) || value.observation !== 'current-file' || typeof value.source_label !== 'string' || +function fileFor(value, identity) { + // A local record observes the selected file itself; every other adapter observes a current server file. + const observation = identity?.kind === 'local-file' ? 'selected-local-file' : 'current-file'; + if (!object(value) || value.observation !== observation || typeof value.source_label !== 'string' || !['image', 'video'].includes(value.media_type) || typeof value.extension !== 'string' || (value.format !== null && typeof value.format !== 'string')) throw new TypeError('Invalid inspection file'); const file = { observation: value.observation, source_label: value.source_label, media_type: value.media_type, extension: value.extension, format: value.format }; @@ -281,7 +283,7 @@ export function projectInspectionPayload(result, identity, download, extractFact }; if (legacy) return record; const resolution = resolutionFor(result.resolution); - record.file = fileFor(result.file); + record.file = fileFor(result.file, identity); record.metadata = metadataFor(result.metadata); record.library = libraryFor(result.library); if (typeof extractFacts !== 'function') throw new TypeError('Inspection facts parser unavailable'); diff --git a/web/js/workspace/local-inspection.mjs b/web/js/workspace/local-inspection.mjs new file mode 100644 index 00000000..402f413c --- /dev/null +++ b/web/js/workspace/local-inspection.mjs @@ -0,0 +1,175 @@ +// One chosen local File, read entirely in this browser, as the inspection wire +// the workspace already renders. The readers are ports of the server's +// (png-metadata.js readPngMetadataFile/readImageHeader, local-video.mjs); the +// projection is local-projection.mjs. Nothing here builds a URL or sends bytes: +// the only URL a handle carries is the session-owned blob: URL it was created with. +import { readVideoTags, selectVideoCarrier } from './local-video.mjs'; +import { projectMetadata, fitInspectionPayload, MAX_RESPONSE_BYTES } from './local-projection.mjs'; +import { projectInspectionPayload } from './inspection-state.mjs'; + +export const LOCAL_OBSERVATION = 'selected-local-file'; +export const LOCAL_SOURCE_LABEL = 'Selected local file'; +// Interim roles until PR L3 ports the inspection resolver: every role is open, and +// the legacy fill in projectInspectionPayload shows what the legacy pages show. +const LOCAL_RESOLUTION_REASON = 'Resolved in this browser from the workflow.'; +const LOCAL_LIBRARY = Object.freeze({ status: 'unavailable', scope: 'saved-library', tags: [], category_scope: null, tags_scope: null }); +const LOCAL_SCOPE = 'local'; +const PREFIX_BYTES = 32; +const MAX_FILENAME_CHARS = 255; +const MEDIA_TYPES = new Set(['image', 'video']); + +const object = value => value !== null && typeof value === 'object' && !Array.isArray(value); +const pngReader = () => globalThis.PngMetadata || globalThis.window?.PngMetadata; + +/** A display name with no path separators or control characters; never empty. */ +export function sanitizeLocalFilename(name) { + const text = typeof name === 'string' ? name : ''; + const cleaned = text.replace(/[\x00-\x1f\x7f]/g, '').replace(/[\\/]/g, '_').trim(); + return [...cleaned].slice(0, MAX_FILENAME_CHARS).join('') || 'file'; +} + +/** `pathlib.Path(name).suffix.lower()`. */ +function localExtension(filename) { + const dot = filename.lastIndexOf('.'); + return dot > 0 && dot < filename.length - 1 ? filename.slice(dot).toLowerCase() : ''; +} + +function classificationOf(value) { + if (!object(value) || !MEDIA_TYPES.has(value.media) || typeof value.kind !== 'string' || !value.kind) throw new TypeError('Unsupported local file'); + return Object.freeze({ media: value.media, kind: value.kind }); +} + +/** + * Run the readers the server would run for these bytes: the bounded PNG reader, + * the JPEG/WebP header reader, or the container tag readers plus the carrier rule. + * Returns `{classification, result}` in the oracle's shape; `result` is null for + * bytes no reader claims. + */ +export async function readLocalFile(file, { classification, signal, pngMetadata = pngReader() } = {}) { + if (!(file instanceof Blob)) throw new TypeError('A local file is required'); + const reader = () => { + if (!pngMetadata || typeof pngMetadata.classifyPrefix !== 'function') throw new TypeError('The local PNG reader is unavailable'); + return pngMetadata; + }; + const prefix = new Uint8Array(await file.slice(0, Math.min(PREFIX_BYTES, file.size)).arrayBuffer()); + const classified = classification === undefined ? reader().classifyPrefix(prefix) : classification; + if (classified === null) return { classification: null, result: null }; + const { media, kind } = classificationOf(classified); + if (kind === 'png') return { classification: classified, result: await reader().readPngMetadataFile(file, { signal, prefix }) }; + if (media === 'image') return { classification: classified, result: await reader().readImageHeader(file, kind) }; + const read = await readVideoTags(file, { signal, prefix, kind }); + return { classification: classified, result: { read, selection: read.status === 'complete' ? selectVideoCarrier(read.tags, { limited: read.limited }) : null } }; +} + +/** + * The server's InspectionRead for a reader result: `read` is null when the server + * would have raised MetadataReadError (unreadable), else + * `{records, record_formats, carrierText, limited}`; `format`, `width` and + * `height` follow `read_inspection_file` (a video has none). + */ +export function toInspectionRead(classification, result) { + const { media, kind } = classificationOf(classification); + const none = { read: null, format: null, width: null, height: null }; + if (kind === 'png') { + if (!object(result) || result.status !== 'complete') return none; + const limited = [...(result.limited ?? [])]; + const record_formats = Object.fromEntries(Object.keys(result.records).map(key => [key, 'original-text'])); + for (const key of limited) record_formats[key] = 'limited'; + return { read: { records: { ...result.records }, record_formats, carrierText: null, limited }, format: 'PNG', width: result.width ?? null, height: result.height ?? null }; + } + if (media === 'image') { + if (!object(result)) return none; + return { read: { records: {}, record_formats: {}, carrierText: null, limited: [] }, format: result.format ?? null, width: result.width ?? null, height: result.height ?? null }; + } + const selection = result?.selection; + if (result?.read?.status !== 'complete' || !object(selection) || selection.status !== 'complete') return none; + return { read: { records: { ...selection.records }, record_formats: { ...selection.record_formats }, carrierText: selection.carrierText ?? null, limited: [...(selection.limited ?? [])] }, format: null, width: null, height: null }; +} + +const dimension = value => Number.isSafeInteger(value) && value > 0 ? value : null; + +/** The `inspection_version: 1` wire for one local read, before the response budget. */ +export function buildLocalPayload({ read, file, classification }) { + const classified = classificationOf(classification); + const inspection = toInspectionRead(classified, read); + const filename = sanitizeLocalFilename(file?.name); + const size = Number.isSafeInteger(file?.size) && file.size >= 0 ? file.size : null; + const modified = typeof file?.lastModified === 'number' && Number.isFinite(file.lastModified) && file.lastModified >= 0 ? file.lastModified / 1000 : null; + return { + success: true, inspection_version: 1, + file: { + observation: LOCAL_OBSERVATION, source_label: LOCAL_SOURCE_LABEL, media_type: classified.media, extension: localExtension(filename), + format: inspection.format, size, width: dimension(inspection.width), height: dimension(inspection.height), modified_time: modified, + }, + prompts: { positive: { status: 'unresolved' }, negative: { status: 'unresolved' } }, + resolution: { state: 'unresolved', sampler_id: null, reason: LOCAL_RESOLUTION_REASON, reason_code: null, node: null }, + metadata: projectMetadata(inspection.read), + library: { ...LOCAL_LIBRARY, tags: [] }, + }; +} + +/** The server's 8 MiB response budget, applied to the local wire with the same omission order. */ +export function fitLocalPayload(wire, limit = MAX_RESPONSE_BYTES) { + return fitInspectionPayload(wire, limit); +} + +// Handles this module created, by a private token: the controller and the viewer +// session spread-copy descriptors, so a copy of an owned handle is the same +// selection, while a hand-made descriptor or a tampered URL is foreign. +const TOKEN = Symbol('local-file'); +const OWNED = new WeakMap(); +let sequence = 0; + +/** + * One selection of a local file. `url` is the session's own `blob:` URL (or null + * when none could be created); every selection gets a new key, so choosing the + * same bytes twice never reuses a stale read. + */ +export function createLocalFileHandle({ file, url = null, classification, filename = file?.name }) { + if (!(file instanceof Blob)) throw new TypeError('A local file is required'); + if (url !== null && (typeof url !== 'string' || !url.startsWith('blob:'))) throw new TypeError('Local media must be a blob: URL'); + const classified = classificationOf(classification); + const token = Object.freeze({}); + const secret = Object.freeze({ key: `local:${++sequence}`, filename: sanitizeLocalFilename(filename), url, file, classification: classified }); + OWNED.set(token, secret); + return Object.freeze({ kind: 'local-file', key: secret.key, filename: secret.filename, is_video: classified.media === 'video', url, file, classification: classified, [TOKEN]: token }); +} + +/** The owned selection behind a handle (or a copy of one); throws for anything else. */ +function owned(handle) { + const secret = object(handle) ? OWNED.get(handle[TOKEN]) : undefined; + if (!secret || secret.key !== handle.key || secret.url !== handle.url || secret.file !== handle.file || secret.filename !== handle.filename) throw new TypeError('Unknown local file'); + return secret; +} + +function validate(handle) { + owned(handle); +} + +function originalDownload(handle) { + const secret = owned(handle); + if (secret.url === null) throw new TypeError('Original media is unavailable'); + return { url: secret.url, filename: secret.filename }; +} + +export const localFileAdapter = Object.freeze({ + validate, + key(handle) { return owned(handle).key; }, + scope(handle) { owned(handle); return LOCAL_SCOPE; }, + // An inert sentinel: the controller's same-key check works and no URL is ever built. + inspectionPath(handle) { return `local-file:${owned(handle).key}`; }, + async read(handle, { signal } = {}) { + const secret = owned(handle); + const { result } = await readLocalFile(secret.file, { classification: secret.classification, signal }); + return fitLocalPayload(buildLocalPayload({ read: result, file: { name: secret.filename, size: secret.file.size, lastModified: secret.file.lastModified }, classification: secret.classification })); + }, + toRecord(result, handle, extractFacts) { + const secret = owned(handle); + if (!object(result) || result.success !== true || result.inspection_version !== 1) throw new TypeError('Invalid local inspection'); + return projectInspectionPayload(result, { kind: 'local-file', key: secret.key, path: secret.filename }, + secret.url === null ? null : originalDownload(handle), extractFacts); + }, + originalDownload, + // Session-owned, never resolved through the API client. + mediaUrl(handle) { return owned(handle).url; }, +}); diff --git a/web/js/workspace/local-projection.mjs b/web/js/workspace/local-projection.mjs new file mode 100644 index 00000000..5d6a35f1 --- /dev/null +++ b/web/js/workspace/local-projection.mjs @@ -0,0 +1,386 @@ +// Browser port of utils/inspection_response.py: the strict parse (Decimal +// spelling, no duplicate keys, NaN/Infinity as null, integers past 2^53-1 as +// text), the prompt/workflow/carrier projection with its depth and node +// ceilings, `_metadata`, and the response-size omission steps of +// `_encode_inspection`. A local file must project exactly as the server does for +// the same bytes; tests/test_local_inspect_parity.py holds both to one answer. +// Pure: no DOM, no I/O. + +export const MAX_RESPONSE_BYTES = 8 * 1024 * 1024; +export const MAX_PROJECTION_DEPTH = 64; +// Backstop, not an expected outcome: executable API nodes for a prompt; UI nodes +// including subgraph definitions for a workflow. +export const GRAPH_NODE_CEILING = 20000; +export const RECORD_KEYS = Object.freeze(['prompt', 'workflow', 'parameters', 'comfyassets_prompt_pairs']); +const FORMATS = new Set(['original-text', 'reserialized-json', 'invalid', 'limited']); +const PROMPT_NODE_KEYS = new Set(['class_type', 'inputs']); +const MAX_SAFE_DIGITS = '9007199254740991'; +// Python's int() refuses more digits than sys.get_int_max_str_digits() (4300). +const INT_MAX_STR_DIGITS = 4300; +// decimal's exact constructor (libmpdec, 64-bit) refuses values outside its +// maximal context: adjusted exponent above MAX_EMAX or exponent below Etiny. +const DECIMAL_MAX_EMAX = 999999999999999999n; +const DECIMAL_ETINY = -1999999999999999997n; +const NUMBER = /-?(?:0|[1-9][0-9]*)(\.[0-9]+)?([eE][-+]?[0-9]+)?/y; +const STRING_STOP = /["\\\u0000-\u001f]/g; +const HEX4 = /^[0-9a-fA-F]{4}$/; +const ESCAPES = { '"': '"', '\\': '\\', '/': '/', b: '\b', f: '\f', n: '\n', r: '\r', t: '\t' }; +// Python's json: `null`/`true`/`false`, then the constants parse_constant receives. +const LITERALS = [['null', null], ['true', true], ['false', false], ['NaN', null], ['Infinity', null], ['-Infinity', null]]; + +/** A present graph cannot be projected within inspection limits (`_ProjectionLimit`). */ +export class ProjectionLimit extends Error { + constructor() { + super('Projection exceeds inspection limits'); + this.name = 'ProjectionLimit'; + } +} + +/** The server's ValueError/TypeError family: the text or tree is malformed. */ +export class ProjectionError extends Error { + constructor(message) { + super(message); + this.name = 'ProjectionError'; + } +} + +const isArray = Array.isArray; +const isObject = value => value !== null && typeof value === 'object' && !isArray(value); +const isContainer = value => value !== null && typeof value === 'object'; + +/** Python's `str(Decimal(literal))` from the literal's digits and exponent, never through a float. */ +export function spellDecimal(literal) { + const negative = literal[0] === '-'; + const body = negative ? literal.slice(1) : literal; + const exponentAt = body.search(/[eE]/); + const mantissa = exponentAt < 0 ? body : body.slice(0, exponentAt); + const dot = mantissa.indexOf('.'); + const integer = dot < 0 ? mantissa : mantissa.slice(0, dot), fraction = dot < 0 ? '' : mantissa.slice(dot + 1); + const exponent = (exponentAt < 0 ? 0n : BigInt(body.slice(exponentAt + 1))) - BigInt(fraction.length); + const digits = (integer + fraction).replace(/^0+/, '') || '0'; + const adjusted = exponent + BigInt(digits.length) - 1n; + if (adjusted > DECIMAL_MAX_EMAX || exponent < DECIMAL_ETINY) throw new ProjectionError('JSON decimal exceeds supported range'); + const leftDigits = exponent + BigInt(digits.length); + const dotPlace = exponent <= 0n && leftDigits > -6n ? leftDigits : 1n; + let intPart, fracPart; + if (dotPlace <= 0n) { + intPart = '0'; fracPart = '.' + '0'.repeat(Number(-dotPlace)) + digits; + } else if (dotPlace >= BigInt(digits.length)) { + intPart = digits + '0'.repeat(Number(dotPlace - BigInt(digits.length))); fracPart = ''; + } else { + intPart = digits.slice(0, Number(dotPlace)); fracPart = '.' + digits.slice(Number(dotPlace)); + } + const shift = leftDigits - dotPlace; + const exp = shift === 0n ? '' : `E${shift > 0n ? '+' : '-'}${shift < 0n ? -shift : shift}`; + return (negative ? '-' : '') + intPart + fracPart + exp; +} + +function numberValue(match) { + const [literal, fraction, exponent] = match; + if (fraction || exponent) return spellDecimal(literal); + const digits = literal[0] === '-' ? literal.slice(1) : literal; + if (digits.length > INT_MAX_STR_DIGITS) throw new ProjectionError('Exceeds the limit for integer string conversion'); + if (digits.length > MAX_SAFE_DIGITS.length || (digits.length === MAX_SAFE_DIGITS.length && digits > MAX_SAFE_DIGITS)) return literal; + return Number(literal) || 0; +} + +/** + * `json.loads(text, parse_float=Decimal, object_pairs_hook=_pairs, parse_constant=_nonfinite)` + * with its results already normalized: objects have a null prototype, fractions and + * exponents are Decimal text, integers past 2^53-1 are their literal text, and + * NaN/Infinity are null. Iterative, so nesting depth never overflows the stack. + */ +export function parseStrictJson(text) { + if (typeof text !== 'string') throw new ProjectionError('JSON text must be a string'); + let pos = 0; + const fail = message => new ProjectionError(`${message} at ${pos}`); + const skip = () => { + while (pos < text.length) { + const c = text.charCodeAt(pos); + if (c !== 0x20 && c !== 0x09 && c !== 0x0a && c !== 0x0d) break; + pos++; + } + }; + function string() { + pos++; + let out = ''; + for (;;) { + STRING_STOP.lastIndex = pos; + const stop = STRING_STOP.exec(text); + if (!stop) throw fail('Unterminated string'); + out += text.slice(pos, stop.index); + pos = stop.index + 1; + if (stop[0] === '"') return out; + if (stop[0] !== '\\') throw fail('Invalid control character'); + const escape = text[pos]; + if (escape === 'u') { + const hex = text.slice(pos + 1, pos + 5); + if (!HEX4.test(hex)) throw fail('Invalid \\uXXXX escape'); + pos += 5; + const code = parseInt(hex, 16); + if (code >= 0xd800 && code <= 0xdbff && text[pos] === '\\' && text[pos + 1] === 'u') { + const low = text.slice(pos + 2, pos + 6); + const lowCode = HEX4.test(low) ? parseInt(low, 16) : -1; + if (lowCode >= 0xdc00 && lowCode <= 0xdfff) { + out += String.fromCharCode(code, lowCode); + pos += 6; + continue; + } + } + out += String.fromCharCode(code); + } else if (Object.hasOwn(ESCAPES, escape)) { + out += ESCAPES[escape]; + pos++; + } else throw fail('Invalid \\escape'); + } + } + function key() { + if (text[pos] !== '"') throw fail('Expecting property name enclosed in double quotes'); + const name = string(); + skip(); + if (text[pos] !== ':') throw fail("Expecting ':' delimiter"); + pos++; + skip(); + return name; + } + function scalar() { + if (text[pos] === '"') return string(); + for (const [literal, value] of LITERALS) { + if (text.startsWith(literal, pos)) { + pos += literal.length; + return value; + } + } + NUMBER.lastIndex = pos; + const match = NUMBER.exec(text); + if (!match) throw fail('Expecting value'); + pos += match[0].length; + return numberValue(match); + } + skip(); + const stack = []; + let result; + for (;;) { + let value; + if (text[pos] === '{') { + pos++; skip(); + const frame = { value: Object.create(null), object: true, key: null, duplicate: false }; + if (text[pos] === '}') pos++; + else { frame.key = key(); stack.push(frame); continue; } + value = frame.value; + } else if (text[pos] === '[') { + pos++; skip(); + const frame = { value: [], object: false }; + if (text[pos] === ']') pos++; + else { stack.push(frame); continue; } + value = frame.value; + } else value = scalar(); + // Attach the finished value, closing containers whose last member it was. + for (;;) { + if (!stack.length) { result = value; break; } + const frame = stack[stack.length - 1]; + if (frame.object) { + if (Object.hasOwn(frame.value, frame.key)) frame.duplicate = true; + frame.value[frame.key] = value; + } else frame.value.push(value); + skip(); + if (text[pos] === ',') { + pos++; skip(); + if (frame.object) frame.key = key(); + break; + } + if (text[pos] !== (frame.object ? '}' : ']')) throw fail("Expecting ',' delimiter"); + pos++; + stack.pop(); + // `_pairs` runs when the object closes, after everything inside it parsed. + if (frame.object && frame.duplicate) throw new ProjectionError('Duplicate JSON key'); + value = frame.value; + } + if (!stack.length) break; + } + skip(); + if (pos !== text.length) throw fail('Extra data'); + return result; +} + +/** `_normalize`: walk once; unretained values are only checked against the depth bound. */ +function normalize(value, depth, retain) { + if (!isContainer(value)) return retain ? value : null; + if (depth > MAX_PROJECTION_DEPTH) throw new ProjectionLimit(); + if (isArray(value)) { + const result = retain ? [] : null; + for (const child of value) { + const normalized = normalize(child, depth + 1, retain); + if (retain) result.push(normalized); + } + return result; + } + const result = retain ? Object.create(null) : null; + for (const key of Object.keys(value)) { + const normalized = normalize(value[key], depth + 1, retain); + if (retain) result[key] = normalized; + } + return result; +} + +/** `_project_prompt`: each API node keeps exactly `class_type` and `inputs`; other keys are validated and dropped. */ +function projectPrompt(tree, depth) { + if (!isObject(tree)) throw new ProjectionError('Prompt graph must be an object of nodes'); + if (depth + 1 > MAX_PROJECTION_DEPTH) throw new ProjectionLimit(); + const result = Object.create(null); + for (const id of Object.keys(tree)) { + const node = tree[id]; + if (!isObject(node)) { + result[id] = normalize(node, depth + 1, true); + continue; + } + const projected = Object.create(null); + for (const key of Object.keys(node)) { + const keep = PROMPT_NODE_KEYS.has(key); + const normalized = normalize(node[key], depth + 2, keep); + if (keep) projected[key] = normalized; + } + result[id] = projected; + } + return result; +} + +function project(tree, kind, depth) { + if (kind === 'prompt') return projectPrompt(tree, depth); + if (kind === 'workflow') return normalize(tree, depth, true); + if (!isObject(tree)) throw new ProjectionError('Malformed carrier'); + const result = Object.create(null); + for (const key of Object.keys(tree)) { + const member = tree[key]; + if (key !== 'prompt' && key !== 'workflow') normalize(member, depth + 1, false); + else if (typeof member === 'string') result[key] = member; // projected by its own kind when read + else if (key === 'prompt' && !isObject(member)) result[key] = normalize(member, depth + 1, true); + else result[key] = project(member, key, depth + 1); + } + return result; +} + +/** `_strict_projection`: parse exactly and project by record kind ('prompt'|'workflow'|'carrier'). */ +export function strictProjection(text, kind) { + return project(parseStrictJson(text), kind, 1); +} + +function nodeCount(value, workflow) { + if (!workflow) return Object.keys(value).length; + const nodes = value.nodes; + let count = isArray(nodes) ? nodes.length : 0; + const definitions = value.definitions; + const subgraphs = isObject(definitions) ? definitions.subgraphs : null; + for (const subgraph of isArray(subgraphs) ? subgraphs : []) { + const inner = isObject(subgraph) ? subgraph.nodes : null; + count += isArray(inner) ? inner.length : 0; + } + return count; +} + +function graph(value, workflow) { + if (!isObject(value)) return { status: 'malformed', value: null }; + if (nodeCount(value, workflow) > GRAPH_NODE_CEILING) return { status: 'limited', value: null }; + return { status: 'parsed', value }; +} + +function outcome(error) { + if (error instanceof ProjectionLimit) return { status: 'limited', value: null }; + if (error instanceof ProjectionError) return { status: 'malformed', value: null }; + throw error; +} + +/** + * One record's graph as the inspector projects it: `{status, value}` with `status` + * `parsed` (the projected graph the facts parser receives), `limited` (past the + * depth or node bound) or `malformed`. + */ +export function projectGraph(text, kind) { + try { + return graph(strictProjection(text, kind), kind === 'workflow'); + } catch (error) { + return outcome(error); + } +} + +/** + * `_metadata` for one local read: `{records, record_formats, carrierText, limited}` + * (the server's InspectionRead) or null when the file's metadata is unreadable. + */ +export function projectMetadata(inspection) { + const formats = {}, raw = {}; + if (inspection) { + for (const [key, value] of Object.entries(inspection.record_formats ?? {})) { + if (RECORD_KEYS.includes(key) && FORMATS.has(value)) formats[key] = value; + } + // A record over its read budget has no text: like `invalid`, raw omits it, + // and its graph is `limited` rather than parsed from nothing. + for (const key of inspection.limited ?? []) if (RECORD_KEYS.includes(key)) formats[key] = 'limited'; + for (const [key, value] of Object.entries(inspection.records ?? {})) { + if (RECORD_KEYS.includes(key) && typeof value === 'string' && formats[key] !== 'limited') raw[key] = value; + } + } + const result = { + status: inspection === null || inspection === undefined ? 'unreadable' : Object.keys(raw).length || Object.keys(formats).length ? 'available' : 'absent', + omitted_reason: null, raw, record_formats: formats, parameters: Object.hasOwn(raw, 'parameters') ? raw.parameters : null, + }; + let carrier = null, failed = null; + if (inspection && inspection.carrierText !== null && inspection.carrierText !== undefined) { + try { carrier = strictProjection(inspection.carrierText, 'carrier'); } catch (error) { failed = outcome(error).status; } + } + for (const [key, field] of [['prompt', 'prompt_graph'], ['workflow', 'workflow_graph']]) { + let value = { status: 'absent', value: null }; + if (Object.hasOwn(raw, key) || Object.hasOwn(formats, key)) { + if (failed || formats[key] === 'limited') value.status = failed || 'limited'; + else if (formats[key] === 'invalid') value.status = 'malformed'; + else if (carrier === null && formats[key] === 'reserialized-json') value.status = 'limited'; + else if (carrier === null) value = projectGraph(raw[key], key); + else { + try { + let member = carrier[key]; + if (typeof member === 'string') member = strictProjection(member, key); + value = graph(member, key === 'workflow'); + } catch (error) { value.status = outcome(error).status; } + } + } + result[field] = value; + } + return result; +} + +/** UTF-8 length of `JSON.stringify(value)`: the server's `_bytes` count (lone surrogates are already escaped). */ +export function inspectionBytes(value) { + const text = JSON.stringify(value); + let count = 0; + for (let index = 0; index < text.length; index++) { + const code = text.charCodeAt(index); + if (code < 0x80) count += 1; + else if (code < 0x800) count += 2; + else if (code >= 0xd800 && code <= 0xdbff) { count += 4; index++; } else count += 3; + } + return count; +} + +const withMetadata = (payload, changes) => ({ ...payload, metadata: { ...payload.metadata, ...changes } }); + +/** + * The omission steps of `_encode_inspection`: the workflow graph first, then the + * prompt graph, then all raw records atomically, each measured as encoded bytes. + * Returns a new payload; throws when even the bare payload is over `limit`. + */ +export function fitInspectionPayload(payload, limit = MAX_RESPONSE_BYTES) { + let fitted = payload, body = inspectionBytes(payload); + for (const field of ['workflow_graph', 'prompt_graph']) { + if (body <= limit) break; + if (fitted.metadata[field].status !== 'absent') { + fitted = withMetadata(fitted, { [field]: { status: 'limited', value: null } }); + body = inspectionBytes(fitted); + } + } + if (body > limit) { + fitted = withMetadata(fitted, { raw: {}, parameters: null, status: 'limited', omitted_reason: 'response-size-limit' }); + body = inspectionBytes(fitted); + } + if (body > limit) throw new RangeError('Inspection response exceeds limits'); + return fitted; +}