feat: add highlighted protocol views and inline operation forms
Reference site / build (push) Canceled after 0s
Reference site / build (push) Canceled after 0s
Expose Rust Text View tokens and safely highlight signatures and JSON results. Add animated Interface and inline Run disclosures with a shared 10ms line cadence, typed argument inputs, and explicit execution. Update documentation and browser regressions for the new controls and explicit counter-value responses.
This commit is contained in:
@@ -46,9 +46,15 @@ Worldspace path、表示用の既存識別子、Web URLは別です。たとえ
|
||||
|
||||
1. Protocolを開くと、Rust `wip-text-view`が生成した選択済みObjectのText Viewを表示します。ツリーで別Objectを選ぶと、そのText Viewに切り替わります。同じObjectの再選択でもObserveします。
|
||||
2. 未取得のInterfaceは`? /::document`の形式で表示します。クリックするとDescriptorとInterface Text Viewを取得して`v /::document`となり、再クリックで`> /::document`に折りたたみます。取得済みの再展開はClientのfresh cacheを利用します。
|
||||
3. 展開したInterfaceのDescriptorにあるOperationを選び、引数を入力して`Execute Operation`を押します。文書の`document.read()`は引数なしなので、Argumentsは`{}`です。Playgroundの操作ではDescriptorに応じて引数を入力します。
|
||||
3. 各Operationのコメントと宣言の間にある`▶ Run`を押すと、その場に`create(name: "…")`のような引数入力フォームが展開されます。`Execute`または単一行入力でのEnterで実行します。引数なしの`document.read()`もフォームを開くだけでは呼び出さず、明示実行が必要です。フォームは再クリックまたはEscapeで閉じられます。
|
||||
|
||||
Argumentsはパラメーター名からtagged ValueへのJSON recordです。たとえば`{"name":{"kind":"string","value":"example"}}`。`integer`は精度を失わない10進文字列(Protocol Integerの範囲は±9,007,199,254,740,991)、`bytes`は0〜255の配列、`record`と`list`は再帰的なtagged Value、`unit`は`{"kind":"unit"}`を使います。入力上限は64KiB・32階層・4096値、path/scope/name/operationは各4096 UTF-8 bytesです。ClientはDescriptorに基づいて引数と操作の前提を検証します。
|
||||
文字列は引用符内の欄にそのまま入力し、整数は10進表記で入力します。UIがパラメーターごとにtagged Valueへ変換し、WASM APIには従来と同じJSON record(例:`{"name":{"kind":"string","value":"example"}}`)を渡します。`integer`はJSのNumberを経由せず文字列として渡し、Protocol Integerの範囲(±9,007,199,254,740,991)などはClientで検証します。省略可能な引数はincludeで送信を選択でき、複合型・名前付き型には個別のtagged Value JSON欄を使います。`bytes`は0〜255の配列、`record`と`list`は再帰的なtagged Value、`unit`は`{"kind":"unit"}`です。API入力上限は64KiB・32階層・4096値、path/scope/name/operationは各4096 UTF-8 bytesです。
|
||||
|
||||
Object/Interfaceのハイライトには`wip-text-view`の`tokenize_object` / `tokenize_interface`を使います。WASMは同じtoken列から`textView`と`textViewTokens`(kind/text)を返し、UIは文字列を再解析せず、安全なテキストノードで種別を着色します。ライト/ダークのパレットは`site/src/styles/text-view.css`に定義しています。空白・改行・引用は保持し、未知の種別は無装飾、不整合やtokenなしの応答は元のplain textで表示します。
|
||||
|
||||
InterfaceとRunフォームの開閉は同じ行アニメーションを使います。展開時は上から、折りたたみ時は下から1行10msずつフェードし、高さも行数×10msで変化します。reduced-motion設定と印刷では省略します。
|
||||
|
||||
Operation結果のハイライトには[Kirari](https://github.com/vortexkit/kirari)(`@vortexlabs/kirari`)のcoreとJSON grammarだけを使い、token配列を安全なspan/text nodeへ変換します。キー・文字列・数値・真偽値・null・記号をライト/ダーク対応で色分けし、整形済みJSONの文字列は変更しません。ハイライトに失敗した場合もplain textで結果を保持します。
|
||||
|
||||
Object/InterfaceのText ViewとOperation結果は別領域です。結果はpath・Interface・Operation付きのtagged JSONで、本文HTMLも文字列として表示し、markupとして実行しません。対象・Interface・Operationの変更で以前の結果表示を消します。ObserveとInterface取得はClientのfresh cacheを使えますが、Operationは明示実行ごとに1回呼び出し、自動再試行しません。ページ遷移・モード往復ではsessionを保持し、再読込で新sessionになります。
|
||||
|
||||
@@ -68,12 +74,12 @@ Object/InterfaceのText ViewとOperation結果は別領域です。結果はpath
|
||||
|
||||
- `collection.create(name)` / `remove(name)`:子Objectを作成・削除。名前は`[A-Za-z0-9_-]{1,32}`、最大32個。新規値は0です。
|
||||
- `counter.get()` / `increment(amount)` / `reset()`:現在値の取得・増減・0へのリセット。Protocol Integerの範囲外への変更は失敗し、状態を変更しません。
|
||||
- 初期状態は`apple = 3`、`orange = 0`。値はObject名ではなく、Observeされたdescriptionをツリー横に表示します。
|
||||
- 操作成功後に親と選択中Objectを再Observeします。変更可能なPlaygroundのObserveはClientの`refresh_observed`で実取得し、Call結果から子一覧や値を捏造しません。再観測に失敗しても成功済みOperationを再実行しません。
|
||||
- 初期状態は`apple = 3`、`orange = 0`。ツリーはObject名だけを表示します。`description`は`An in-memory counter.`という説明文で、現在値や機械可読データとして解釈しません。値は明示的な`counter.get()` / `increment()` / `reset()`のOperationレスポンスに表示します。
|
||||
- 操作成功後に親と選択中Objectを再Observeします。変更可能なPlaygroundのObserveはClientの`refresh_observed`で実取得し、作成・削除による子一覧の変更をツリーへ反映します。Observeからカウンター値を読み取ったり、値の取得Operationを自動実行したりしません。再観測に失敗しても成功済みOperationを再実行しません。
|
||||
- 子の選択は`/playground/?mode=protocol&object=%2Fplayground%2Fapple`のようなURLで保持します。ページ・履歴・Read/Protocol往復ではProtocol sessionが残り、ブラウザの再読み込みで初期化します。削除済みObjectへの履歴移動はエラーを表示し、復活させません。
|
||||
- 通常Readは静的な操作説明を表示します。状態の永続化・外部送信・文書変更はありません。
|
||||
|
||||
例:createのArgumentsは`{"name":{"kind":"string","value":"banana"}}`、incrementは`{"amount":{"kind":"integer","value":"2"}}`です。
|
||||
例:createの`name`欄に`banana`、incrementの`amount`欄に`2`を入力すると、それぞれ`create(name: "banana")`、`increment(amount: 2)`の呼び出しになります。
|
||||
|
||||
### 実行上の境界
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ export default defineConfig({
|
||||
site,
|
||||
base,
|
||||
trailingSlash: 'always',
|
||||
// Browser tests exercise the site, not the development toolbar overlay.
|
||||
devToolbar: { enabled: process.env.SITE_BROWSER_TEST !== '1' },
|
||||
markdown: {
|
||||
processor: unified({ remarkPlugins: [[documentLinks, { base }]], smartypants: false }),
|
||||
shikiConfig: { themes: { light: 'github-light', dark: 'github-dark' }, langAlias: { wip: 'text' } },
|
||||
|
||||
@@ -161,10 +161,11 @@ impl BrowserSession {
|
||||
// Reconstruct requested coverage from fresh Client state, never from Host data.
|
||||
let observation = self.manual_observation(path, depth)?;
|
||||
let object = self.fresh_object(path)?;
|
||||
let text =
|
||||
wip_text_view::render_object(&object).map_err(|e| ManualError::new("render", e))?;
|
||||
let tokens =
|
||||
wip_text_view::tokenize_object(&object).map_err(|e| ManualError::new("render", e))?;
|
||||
let text = wip_text_view::to_plain_text(&tokens);
|
||||
Ok(
|
||||
json!({"path": path, "observation": observation, "textView": text, "cached": cached, "stats": self.manual_stats()}),
|
||||
json!({"path": path, "observation": observation, "textView": text, "textViewTokens": text_view_tokens(&tokens), "cached": cached, "stats": self.manual_stats()}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -221,13 +222,14 @@ impl BrowserSession {
|
||||
.descriptor
|
||||
.as_ref()
|
||||
.ok_or_else(missing_observation)?;
|
||||
let text = wip_text_view::render_interface(&wip_text_view::Interface {
|
||||
let tokens = wip_text_view::tokenize_interface(&wip_text_view::Interface {
|
||||
reference: &reference,
|
||||
descriptor,
|
||||
})
|
||||
.map_err(|e| ManualError::new("render", e))?;
|
||||
let text = wip_text_view::to_plain_text(&tokens);
|
||||
Ok(
|
||||
json!({"reference": reference_json(&reference), "descriptor": descriptor_json(descriptor), "textView": text, "cached": cached, "stats": self.manual_stats()}),
|
||||
json!({"reference": reference_json(&reference), "descriptor": descriptor_json(descriptor), "textView": text, "textViewTokens": text_view_tokens(&tokens), "cached": cached, "stats": self.manual_stats()}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -277,6 +279,34 @@ impl BrowserSession {
|
||||
}
|
||||
}
|
||||
|
||||
// Display-only DTOs: preserve WIP text verbatim, not HTML or protocol identity.
|
||||
fn text_view_tokens(tokens: &[wip_text_view::Token]) -> Vec<Json> {
|
||||
tokens
|
||||
.iter()
|
||||
.map(|token| json!({"kind": text_view_token_kind(token.kind()), "text": token.text()}))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn text_view_token_kind(kind: wip_text_view::TokenKind) -> &'static str {
|
||||
use wip_text_view::TokenKind;
|
||||
match kind {
|
||||
TokenKind::Keyword => "keyword",
|
||||
TokenKind::TypeName => "type-name",
|
||||
TokenKind::OperationName => "operation-name",
|
||||
TokenKind::ParameterName => "parameter-name",
|
||||
TokenKind::FieldName => "field-name",
|
||||
TokenKind::CaseName => "case-name",
|
||||
TokenKind::String => "string",
|
||||
TokenKind::Reference => "reference",
|
||||
TokenKind::Documentation => "documentation",
|
||||
TokenKind::Symbol => "symbol",
|
||||
TokenKind::Whitespace => "whitespace",
|
||||
TokenKind::Newline => "newline",
|
||||
TokenKind::Indentation => "indentation",
|
||||
_ => "plain",
|
||||
}
|
||||
}
|
||||
|
||||
fn missing_observation() -> ManualError {
|
||||
ManualError {
|
||||
kind: "client",
|
||||
|
||||
@@ -166,6 +166,9 @@ fn signatures_are_real_renderer_output_and_exclude_metadata() {
|
||||
observed["textView"],
|
||||
wip_text_view::render_object(&object).unwrap()
|
||||
);
|
||||
assert_display_tokens(&observed, &wip_text_view::tokenize_object(&object).unwrap());
|
||||
let cached = s.manual_observe("/quoted", 0.0).unwrap();
|
||||
assert_eq!(cached["textViewTokens"], observed["textViewTokens"]);
|
||||
let text = observed["textView"].as_str().unwrap();
|
||||
assert!(text.contains("引用 \\\"quote\\\" \\\\ space\\nnext\\tline "));
|
||||
assert!(text.ends_with("}\n"));
|
||||
@@ -187,6 +190,17 @@ fn signatures_are_real_renderer_output_and_exclude_metadata() {
|
||||
})
|
||||
.unwrap()
|
||||
);
|
||||
assert_display_tokens(
|
||||
&fetched,
|
||||
&wip_text_view::tokenize_interface(&wip_text_view::Interface {
|
||||
reference: &reference,
|
||||
descriptor,
|
||||
})
|
||||
.unwrap(),
|
||||
);
|
||||
let cached = s.manual_interface("/", "document").unwrap();
|
||||
assert_eq!(cached["textViewTokens"], fetched["textViewTokens"]);
|
||||
assert_eq!(fetched["reference"], reference_json(&reference));
|
||||
assert_eq!(fetched["descriptor"]["operations"][0]["name"], "read");
|
||||
assert_eq!(
|
||||
fetched["descriptor"]["operations"][0]["parameters"],
|
||||
@@ -194,6 +208,98 @@ fn signatures_are_real_renderer_output_and_exclude_metadata() {
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_display_tokens(response: &Json, tokens: &[wip_text_view::Token]) {
|
||||
let dto = response["textViewTokens"].as_array().unwrap();
|
||||
assert_eq!(dto.len(), tokens.len());
|
||||
for (entry, token) in dto.iter().zip(tokens) {
|
||||
assert_eq!(entry.as_object().unwrap().len(), 2);
|
||||
assert_eq!(entry["kind"], text_view_token_kind(token.kind()));
|
||||
assert_eq!(entry["text"], token.text());
|
||||
}
|
||||
let concatenated: String = dto
|
||||
.iter()
|
||||
.map(|entry| entry["text"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(concatenated, response["textView"].as_str().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_token_kinds_use_the_explicit_kebab_case_whitelist() {
|
||||
use wip_text_view::TokenKind;
|
||||
for (kind, expected) in [
|
||||
(TokenKind::Keyword, "keyword"),
|
||||
(TokenKind::TypeName, "type-name"),
|
||||
(TokenKind::OperationName, "operation-name"),
|
||||
(TokenKind::ParameterName, "parameter-name"),
|
||||
(TokenKind::FieldName, "field-name"),
|
||||
(TokenKind::CaseName, "case-name"),
|
||||
(TokenKind::String, "string"),
|
||||
(TokenKind::Reference, "reference"),
|
||||
(TokenKind::Documentation, "documentation"),
|
||||
(TokenKind::Symbol, "symbol"),
|
||||
(TokenKind::Whitespace, "whitespace"),
|
||||
(TokenKind::Newline, "newline"),
|
||||
(TokenKind::Indentation, "indentation"),
|
||||
] {
|
||||
assert_eq!(text_view_token_kind(kind), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_tokens_preserve_quoted_markup_and_unicode_as_raw_text() {
|
||||
let raw = r#"<script title="引用 🦀">& 'text'</script>"#;
|
||||
let quoted = r#""<script title=\"引用 🦀\">& 'text'</script>""#;
|
||||
let mut data: Json = serde_json::from_str(&manifest()).unwrap();
|
||||
data["nodes"][1]["title"] = json!(raw);
|
||||
let mut s = BrowserSession::new(&data.to_string()).unwrap();
|
||||
let observed = s.manual_observe("/quoted", 0.0).unwrap();
|
||||
let object = s.fresh_object("/quoted").unwrap();
|
||||
assert_eq!(
|
||||
observed["textView"],
|
||||
wip_text_view::render_object(&object).unwrap()
|
||||
);
|
||||
assert_display_tokens(&observed, &wip_text_view::tokenize_object(&object).unwrap());
|
||||
assert!(
|
||||
observed["textViewTokens"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!({"kind": "string", "text": quoted}))
|
||||
);
|
||||
|
||||
s.manual_interface("/", "document").unwrap();
|
||||
let reference = Worldspace::document_interface();
|
||||
let mut descriptor = s
|
||||
.client
|
||||
.interface(&s.session, &reference)
|
||||
.unwrap()
|
||||
.descriptor
|
||||
.clone()
|
||||
.unwrap();
|
||||
descriptor.documentation = Some(Documentation {
|
||||
summary: raw.into(),
|
||||
details: None,
|
||||
});
|
||||
descriptor.operations[0].name = raw.into();
|
||||
let interface = wip_text_view::Interface {
|
||||
reference: &reference,
|
||||
descriptor: &descriptor,
|
||||
};
|
||||
let tokens = wip_text_view::tokenize_interface(&interface).unwrap();
|
||||
let dto = json!({
|
||||
"textView": wip_text_view::render_interface(&interface).unwrap(),
|
||||
"textViewTokens": text_view_tokens(&tokens),
|
||||
});
|
||||
// JSON transport must retain literal text, never HTML entities or generated markup.
|
||||
let decoded: Json = serde_json::from_str(&dto.to_string()).unwrap();
|
||||
assert_display_tokens(&decoded, &tokens);
|
||||
let entries = decoded["textViewTokens"].as_array().unwrap();
|
||||
let quoted_identifier = r#"`<script title=\"引用 🦀\">& 'text'</script>`"#;
|
||||
assert!(entries.contains(&json!({"kind": "operation-name", "text": quoted_identifier})));
|
||||
assert!(entries.contains(&json!({"kind": "documentation", "text": format!("/// {quoted}")})));
|
||||
assert_eq!(descriptor.operations[0].name, raw);
|
||||
assert_eq!(reference, Worldspace::document_interface());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_tagged_values_round_trip_without_integer_or_bytes_coercion() {
|
||||
let values = vec![
|
||||
|
||||
Generated
+7
@@ -9,6 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@astrojs/markdown-remark": "7.3.2",
|
||||
"@vortexlabs/kirari": "1.1.0",
|
||||
"animejs": "4.5.0",
|
||||
"astro": "7.3.8"
|
||||
},
|
||||
@@ -1932,6 +1933,12 @@
|
||||
"vscode-uri": "^3.0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/@vortexlabs/kirari": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@vortexlabs/kirari/-/kirari-1.1.0.tgz",
|
||||
"integrity": "sha512-HKM06r3Y3hrmPTOeX4r9Q1UqZMoFFwC2MU2BsXDrblPPi5Q1Ht5eDgF6b2mcGO7GKbJpoWkxXWsho7W/uCIbEA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vscode/emmet-helper": {
|
||||
"version": "2.11.0",
|
||||
"resolved": "https://registry.npmjs.org/@vscode/emmet-helper/-/emmet-helper-2.11.0.tgz",
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/markdown-remark": "7.3.2",
|
||||
"@vortexlabs/kirari": "1.1.0",
|
||||
"animejs": "4.5.0",
|
||||
"astro": "7.3.8"
|
||||
},
|
||||
|
||||
@@ -8,10 +8,11 @@ const baseURL = `http://127.0.0.1:${port}${base}/`;
|
||||
export default defineConfig({
|
||||
...preview,
|
||||
// Override rather than merge webServer: dev checks must not require dist/.
|
||||
testMatch: ['wip-loading.spec.ts', 'fonts.spec.ts', 'protocol.spec.ts', 'protocol-api.spec.ts', 'playground.spec.ts'],
|
||||
testMatch: ['wip-loading.spec.ts', 'fonts.spec.ts', 'protocol.spec.ts', 'protocol-api.spec.ts', 'playground.spec.ts', 'text-view.spec.ts', 'operation-form.spec.ts', 'json-view.spec.ts'],
|
||||
use: { ...preview.use, baseURL },
|
||||
webServer: {
|
||||
command: `npm run dev -- --host 127.0.0.1 --port ${port} --ignore-lock`,
|
||||
env: { SITE_BROWSER_TEST: '1' },
|
||||
url: baseURL,
|
||||
reuseExistingServer: false,
|
||||
},
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
import { href } from '../lib/book';
|
||||
---
|
||||
<h1>Playground</h1>
|
||||
<p>ProtocolのOperationでObjectを作り、状態を変えるメモリ内のデモです。<a href={`${href('playground/')}?mode=protocol`}>Protocolで開く</a>と、実際のObserveでカウンターを取得します。</p>
|
||||
<p>ProtocolのOperationでObjectを作り、状態を変えるメモリ内のデモです。<a href={`${href('playground/')}?mode=protocol`}>Protocolで開く</a>と、ObserveでカウンターObjectの一覧を取得します。</p>
|
||||
<ul>
|
||||
<li><code>/::collection</code>:<code>create(name)</code>でカウンターを追加し、<code>remove(name)</code>で削除します。</li>
|
||||
<li><code>/::counter</code>:<code>get()</code>で現在値、<code>increment(amount)</code>で増減、<code>reset()</code>で0に戻します。</li>
|
||||
</ul>
|
||||
<p>初期状態はappleが3、orangeが0です。操作後に再Observeし、ツリーの子Objectと値を更新します。名前は1〜32文字の英数字・<code>_</code>・<code>-</code>、カウンターは最大32個です。</p>
|
||||
<p>Argumentsの入力例:</p>
|
||||
<pre><code>{'{"name":{"kind":"string","value":"banana"}}'}</code></pre>
|
||||
<pre><code>{'{"amount":{"kind":"integer","value":"2"}}'}</code></pre>
|
||||
<p>初期状態はappleが3、orangeが0です。作成・削除は再Observeでツリーに反映します。ツリーにはObject名だけを表示し、値は<code>get()</code>などのOperationレスポンスで確認します。名前は1〜32文字の英数字・<code>_</code>・<code>-</code>、カウンターは最大32個です。</p>
|
||||
<p>Interfaceを開き、各Operationのコメントと宣言の間にある<code>▶ Run</code>を押すと、関数呼び出し風の引数入力欄が展開されます。文字列は引用符の内側、整数は数値の欄に入力し、<code>Execute</code>またはEnterで実行します。</p>
|
||||
<pre><code>{'create(name: "banana")'}</code></pre>
|
||||
<pre><code>{'increment(amount: 2)'}</code></pre>
|
||||
<p>状態はこのタブのProtocol session内だけに保存され、再読み込みで初期状態に戻ります。文書Objectや他の利用者の状態は変更しません。JavaScriptやWASMが利用できない場合は、この説明のみ表示します。</p>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
---
|
||||
import '../styles/protocol.css';
|
||||
import '../styles/text-view.css';
|
||||
import '../styles/json-view.css';
|
||||
---
|
||||
<div class="mode-switch" data-mode-switch hidden role="group" aria-label="表示モード">
|
||||
<button type="button" data-mode="read" aria-pressed="true">Read</button>
|
||||
@@ -9,12 +11,5 @@ import '../styles/protocol.css';
|
||||
<p data-protocol-status role="status" aria-live="polite" hidden></p>
|
||||
<pre class="protocol-signature" tabindex="0" aria-label="Object Text View" data-object-view hidden></pre>
|
||||
<div data-protocol-interfaces aria-label="Interfaces"></div>
|
||||
<div class="protocol-operation" data-protocol-operation-controls hidden>
|
||||
<label>Operation <select data-protocol-operation disabled aria-label="Operation"><option value="">未選択</option></select></label>
|
||||
<label>Arguments (tagged JSON record)
|
||||
<textarea data-protocol-arguments rows="4" spellcheck="false" maxlength="65536" disabled>{'{}'}</textarea>
|
||||
</label>
|
||||
<button type="button" data-protocol-call disabled>Execute Operation</button>
|
||||
</div>
|
||||
<pre tabindex="0" data-protocol-result aria-label="Operation result" hidden></pre>
|
||||
<pre class="protocol-json" tabindex="0" data-protocol-result aria-label="Operation result" hidden></pre>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { tokenize } from '@vortexlabs/kirari/core';
|
||||
import { json } from '@vortexlabs/kirari/languages/json';
|
||||
|
||||
const highlighted = new Set(['property', 'string', 'number', 'boolean', 'null', 'punctuation']);
|
||||
|
||||
/** Highlight presentation only: never turn response strings into executable HTML. */
|
||||
export function renderJson(view: HTMLElement, source: string) {
|
||||
try {
|
||||
const tokens = tokenize(source, json);
|
||||
if (tokens.map(token => token.value).join('') !== source) throw new Error('JSON token text mismatch');
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (const token of tokens) {
|
||||
if (highlighted.has(token.type)) {
|
||||
const span = document.createElement('span');
|
||||
span.className = `kirari-${token.type}`;
|
||||
span.textContent = token.value;
|
||||
fragment.append(span);
|
||||
} else fragment.append(document.createTextNode(token.value));
|
||||
}
|
||||
view.replaceChildren(fragment);
|
||||
} catch {
|
||||
// A presentation failure must not discard a successful result or replay a call.
|
||||
view.textContent = source;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { animateTextViewLines, collapseTextView } from './text-view';
|
||||
|
||||
export interface Operation {
|
||||
name: string;
|
||||
parameters: { name: string; required: boolean; type: { kind: string } }[];
|
||||
}
|
||||
type Execute = (operation: Operation, argumentsJSON: () => string, form: HTMLFormElement) => void;
|
||||
|
||||
function operationForm(operation: Operation, displayName: string, execute: Execute, changed: () => void) {
|
||||
const form = document.createElement('form');
|
||||
form.className = 'protocol-call-form'; form.dataset.operationForm = operation.name;
|
||||
form.setAttribute('aria-label', `${operation.name} arguments`);
|
||||
const opening = document.createElement('span'); opening.textContent = `${displayName}(`;
|
||||
const lines: HTMLElement[] = [opening];
|
||||
form.append(opening);
|
||||
const readers: (() => [string, unknown] | undefined)[] = [];
|
||||
for (const parameter of operation.parameters) {
|
||||
const row = document.createElement('div'); row.className = 'protocol-argument';
|
||||
const label = document.createElement('label');
|
||||
const name = document.createElement('span'); name.textContent = `${parameter.name}:`;
|
||||
label.append(name);
|
||||
const kind = parameter.type.kind;
|
||||
const input = document.createElement(kind === 'boolean' ? 'select' : ['string', 'integer', 'number', 'unit'].includes(kind) ? 'input' : 'textarea');
|
||||
input.setAttribute('aria-label', `${operation.name}.${parameter.name}`);
|
||||
input.setAttribute('spellcheck', 'false');
|
||||
if (input instanceof HTMLSelectElement) {
|
||||
input.append(new Option('false', 'false'), new Option('true', 'true'));
|
||||
} else {
|
||||
input.maxLength = 65536;
|
||||
if (input instanceof HTMLTextAreaElement) {
|
||||
input.rows = 2; input.placeholder = 'tagged Value JSON';
|
||||
input.title = 'Tagged Value JSON, e.g. {"kind":"list","value":[]}';
|
||||
} else {
|
||||
input.type = 'text'; input.autocomplete = 'off';
|
||||
input.value = kind === 'integer' ? '1' : kind === 'number' ? '0' : kind === 'unit' ? 'unit' : '';
|
||||
if (kind === 'unit') input.readOnly = true;
|
||||
}
|
||||
}
|
||||
if (kind === 'string') label.append(document.createTextNode('"'));
|
||||
label.append(input);
|
||||
if (kind === 'string') label.append(document.createTextNode('"'));
|
||||
label.append(document.createTextNode(',')); row.append(label);
|
||||
let include: HTMLInputElement | undefined;
|
||||
if (!parameter.required) {
|
||||
const optional = document.createElement('label'); optional.className = 'protocol-optional';
|
||||
include = document.createElement('input'); include.type = 'checkbox';
|
||||
include.setAttribute('aria-label', `Include ${operation.name}.${parameter.name}`);
|
||||
optional.append(include, document.createTextNode('include')); row.append(optional);
|
||||
input.disabled = true;
|
||||
include.addEventListener('change', () => { input.disabled = !include!.checked; });
|
||||
}
|
||||
readers.push(() => {
|
||||
if (include && !include.checked) return undefined;
|
||||
const value = input.value;
|
||||
if (kind === 'unit') return [parameter.name, { kind }];
|
||||
if (kind === 'string' || kind === 'integer') return [parameter.name, { kind, value }];
|
||||
if (kind === 'boolean') return [parameter.name, { kind, value: value === 'true' }];
|
||||
if (kind === 'number') {
|
||||
const number = Number(value);
|
||||
if (!value.trim() || !Number.isFinite(number)) throw new Error(`${parameter.name}: finite number required`);
|
||||
return [parameter.name, { kind, value: number }];
|
||||
}
|
||||
// Composite/named types retain the existing tagged Value input contract.
|
||||
return [parameter.name, JSON.parse(value)];
|
||||
});
|
||||
form.append(row); lines.push(row);
|
||||
}
|
||||
// No-argument calls are one logical line; otherwise the closing call/Execute
|
||||
// row follows the argument rows with the same cadence as Interface text.
|
||||
const closing = operation.parameters.length ? document.createElement('span') : opening;
|
||||
if (closing !== opening) { form.append(closing); lines.push(closing); }
|
||||
closing.append(document.createTextNode(') '));
|
||||
const submit = document.createElement('button'); submit.type = 'submit';
|
||||
submit.textContent = 'Execute'; submit.dataset.protocolCall = operation.name;
|
||||
submit.setAttribute('aria-label', `Execute ${operation.name}`); closing.append(submit);
|
||||
form.addEventListener('input', changed);
|
||||
form.addEventListener('change', changed);
|
||||
form.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
if (!form.isConnected || form.closest('[inert]') || submit.disabled) return;
|
||||
execute(operation, () => {
|
||||
try {
|
||||
return JSON.stringify(Object.fromEntries(readers.flatMap(read => { const entry = read(); return entry ? [entry] : []; })));
|
||||
} catch (error) { throw new Error(`input: ${String(error)}`); }
|
||||
}, form);
|
||||
});
|
||||
return { form, duration: animateTextViewLines(lines) };
|
||||
}
|
||||
|
||||
/** Rust emits one OperationName per declaration, in Descriptor.operations order.
|
||||
* Bind by that structural order, never by parsing a displayed/escaped identifier. */
|
||||
export function attachOperationControls(view: HTMLElement, operations: readonly Operation[], execute: Execute, changed: () => void) {
|
||||
const declarations = [...view.querySelectorAll<HTMLElement>('.text-view-line')]
|
||||
.filter(line => line.querySelector('[data-text-kind="operation-name"]'));
|
||||
if (declarations.length !== operations.length) return false; // Incompatible/plain-text fallback stays read-only.
|
||||
let opened: { button: HTMLButtonElement; body: HTMLDivElement } | undefined;
|
||||
const close = (animate: boolean) => {
|
||||
if (!opened) return;
|
||||
const { button, body } = opened;
|
||||
button.setAttribute('aria-expanded', 'false'); button.textContent = '▶ Run';
|
||||
if (animate) collapseTextView(body);
|
||||
else { body.hidden = true; body.inert = false; body.replaceChildren(); opened = undefined; }
|
||||
// Keep a closing body reachable so opening another form retires it immediately.
|
||||
};
|
||||
declarations.forEach((line, index) => {
|
||||
const operation = operations[index];
|
||||
const displayName = line.querySelector('[data-text-kind="operation-name"]')!.textContent!;
|
||||
const control = document.createElement('div'); control.className = 'protocol-operation';
|
||||
control.dataset.operationControl = operation.name;
|
||||
const button = document.createElement('button'); button.type = 'button';
|
||||
button.textContent = '▶ Run'; button.setAttribute('aria-label', `Run ${operation.name}`);
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
const body = document.createElement('div'); body.hidden = true;
|
||||
body.id = `${view.id}-operation-${index}`; button.setAttribute('aria-controls', body.id);
|
||||
button.addEventListener('click', () => {
|
||||
if (view.inert || !view.isConnected) return;
|
||||
changed();
|
||||
if (opened?.button === button && button.getAttribute('aria-expanded') === 'true') { close(true); return; }
|
||||
close(false);
|
||||
const { form, duration } = operationForm(operation, displayName, execute, changed);
|
||||
const reveal = document.createElement('div'); reveal.className = 'text-view-reveal';
|
||||
reveal.style.setProperty('--reveal-duration', `${duration}ms`);
|
||||
const content = document.createElement('div'); content.className = 'text-view-lines';
|
||||
content.append(form); reveal.append(content); body.replaceChildren(reveal);
|
||||
body.inert = false; body.hidden = false;
|
||||
button.setAttribute('aria-expanded', 'true'); button.textContent = '▼ Run';
|
||||
opened = { button, body };
|
||||
form.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape') { event.preventDefault(); changed(); close(true); button.focus({ preventScroll: true }); }
|
||||
});
|
||||
form.querySelector<HTMLElement>('input:not(:disabled), select:not(:disabled), textarea:not(:disabled), button')?.focus({ preventScroll: true });
|
||||
});
|
||||
control.append(button, body); line.before(control);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
import type { TreeNode } from '../lib/landing-tree';
|
||||
import { isPlaygroundPath } from '../lib/playground';
|
||||
import type { PlaygroundChild } from './playground-tree';
|
||||
import { renderJson } from './json-view';
|
||||
import { attachOperationControls, type Operation } from './operation-form';
|
||||
import { collapseTextView, renderTextView, revealTextViewLines, type TextView } from './text-view';
|
||||
|
||||
interface Stats { observes: number; interfaces: number; calls: number; }
|
||||
interface Reference { scope: string; name: string; }
|
||||
interface Observation { object: { name: string; description?: string | null; interfaces: Reference[] }; children?: Observation[] | null; }
|
||||
interface Descriptor { operations: { name: string; parameters: { name: string; required: boolean; type: { kind: string } }[] }[]; }
|
||||
interface Observed { path: string; observation: Observation; textView: string; cached: boolean; stats: Stats; }
|
||||
interface Fetched { reference: Reference; descriptor: Descriptor; textView: string; cached: boolean; stats: Stats; }
|
||||
interface Descriptor { operations: Operation[]; }
|
||||
interface Observed extends TextView { path: string; observation: Observation; cached: boolean; stats: Stats; }
|
||||
interface Fetched extends TextView { reference: Reference; descriptor: Descriptor; cached: boolean; stats: Stats; }
|
||||
interface Called { path: string; interface: Reference; operation: string; result: unknown; stats: Stats; }
|
||||
interface ManualSession {
|
||||
observe(path: string, depth: number): string;
|
||||
@@ -83,16 +86,12 @@ export function setupProtocol(stage: HTMLElement, objects: readonly TreeNode[],
|
||||
const article = find<HTMLElement>('[data-selected-content]');
|
||||
const switches = find<HTMLElement>('[data-mode-switch]');
|
||||
const interfaces = find<HTMLElement>('[data-protocol-interfaces]');
|
||||
const interfaceRows: { reference: Reference; button: HTMLButtonElement; view: HTMLPreElement }[] = [];
|
||||
const operations = find<HTMLSelectElement>('[data-protocol-operation]');
|
||||
const args = find<HTMLTextAreaElement>('[data-protocol-arguments]');
|
||||
const callButton = find<HTMLButtonElement>('[data-protocol-call]');
|
||||
const interfaceRows: { reference: Reference; button: HTMLButtonElement; view: HTMLDivElement }[] = [];
|
||||
const objectView = find<HTMLElement>('[data-object-view]');
|
||||
const result = find<HTMLElement>('[data-protocol-result]');
|
||||
const status = find<HTMLElement>('[data-protocol-status]');
|
||||
const operationControls = find<HTMLElement>('[data-protocol-operation-controls]');
|
||||
const showStatus = (text: string) => { status.textContent = text; status.hidden = !text; };
|
||||
const disableControls = () => panel.querySelectorAll<HTMLButtonElement | HTMLSelectElement | HTMLTextAreaElement>('button, select, textarea').forEach(control => { control.disabled = true; });
|
||||
const disableControls = () => panel.querySelectorAll<HTMLButtonElement | HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>('button, input, select, textarea').forEach(control => { control.disabled = true; });
|
||||
const canonical = new Map<string, string>();
|
||||
for (const node of objects) {
|
||||
const parent = node.parent === undefined ? undefined : canonical.get(node.parent);
|
||||
@@ -119,18 +118,17 @@ export function setupProtocol(stage: HTMLElement, objects: readonly TreeNode[],
|
||||
};
|
||||
const refreshStats = () => { if (client && !trapped) stats(JSON.parse(invoke(() => client!.stats()))); };
|
||||
const resetResult = () => { result.textContent = ''; result.hidden = true; };
|
||||
const resetInterface = () => {
|
||||
const resetInterface = (closingView?: HTMLElement) => {
|
||||
fetched = undefined;
|
||||
operationControls.hidden = true;
|
||||
for (const { reference, button, view } of interfaceRows) {
|
||||
button.textContent = `${knownInterfaces.has(referenceKey(reference)) ? '>' : '?'} ${reference.scope}::${reference.name}`;
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
view.hidden = true;
|
||||
view.textContent = '';
|
||||
if (view === closingView) collapseTextView(view);
|
||||
else {
|
||||
view.hidden = true; view.inert = false;
|
||||
view.textContent = '';
|
||||
}
|
||||
}
|
||||
operations.replaceChildren(new Option('未選択', ''));
|
||||
operations.disabled = true;
|
||||
args.value = '{}'; args.disabled = true; callButton.disabled = true;
|
||||
resetResult();
|
||||
};
|
||||
const resetObservation = () => {
|
||||
@@ -216,17 +214,6 @@ export function setupProtocol(stage: HTMLElement, objects: readonly TreeNode[],
|
||||
if ((stage.dataset.mode === 'protocol') !== isProtocolMode()
|
||||
|| (location.pathname === documentPath && selectedPath !== pathFromURL())) showMode();
|
||||
}, { signal, capture: true });
|
||||
operations.addEventListener('change', () => {
|
||||
resetResult();
|
||||
const parameters = fetched?.descriptor.operations.find(operation => operation.name === operations.value)?.parameters ?? [];
|
||||
const template = Object.fromEntries(parameters.filter(parameter => parameter.required).flatMap(parameter => {
|
||||
if (parameter.type.kind === 'string') return [[parameter.name, { kind: 'string', value: '' }]];
|
||||
if (parameter.type.kind === 'integer') return [[parameter.name, { kind: 'integer', value: '1' }]];
|
||||
return [];
|
||||
}));
|
||||
args.value = JSON.stringify(template, null, 2); callButton.disabled = !operations.value;
|
||||
}, { signal });
|
||||
args.addEventListener('input', resetResult, { signal });
|
||||
const observePlayground = (session: ManualSession) => {
|
||||
try {
|
||||
const parent: Observed = JSON.parse(invoke(() => session.observe('/playground', 1)));
|
||||
@@ -248,7 +235,7 @@ export function setupProtocol(stage: HTMLElement, objects: readonly TreeNode[],
|
||||
const observed: Observed = selectedPath === '/playground' && parent ? parent : JSON.parse(invoke(() => session.observe(selectedPath, 1)));
|
||||
observedPath = observed.path;
|
||||
references = observed.observation.object.interfaces;
|
||||
objectView.textContent = observed.textView; objectView.hidden = false;
|
||||
renderTextView(objectView, observed); objectView.hidden = false;
|
||||
interfaces.replaceChildren();
|
||||
references.forEach((reference, index) => {
|
||||
const row = document.createElement('div'); row.className = 'protocol-interface';
|
||||
@@ -257,8 +244,9 @@ export function setupProtocol(stage: HTMLElement, objects: readonly TreeNode[],
|
||||
button.textContent = `${knownInterfaces.has(referenceKey(reference)) ? '>' : '?'} ${reference.scope}::${reference.name}`;
|
||||
button.setAttribute('aria-expanded', 'false');
|
||||
button.title = JSON.stringify(reference);
|
||||
const view = document.createElement('pre');
|
||||
view.className = 'protocol-signature'; view.dataset.interfaceView = '';
|
||||
const view = document.createElement('div');
|
||||
view.className = 'protocol-signature protocol-interface-view'; view.dataset.interfaceView = '';
|
||||
view.setAttribute('role', 'region');
|
||||
view.id = `protocol-interface-view-${index}`; view.tabIndex = 0;
|
||||
view.setAttribute('aria-label', 'Interface Text View'); view.hidden = true;
|
||||
button.setAttribute('aria-controls', view.id);
|
||||
@@ -266,7 +254,7 @@ export function setupProtocol(stage: HTMLElement, objects: readonly TreeNode[],
|
||||
button.addEventListener('click', () => run((session) => {
|
||||
if (selectedPath !== observedPath) throw new Error('Objectを観測してください。');
|
||||
const collapse = button.getAttribute('aria-expanded') === 'true';
|
||||
resetInterface();
|
||||
resetInterface(collapse ? view : undefined);
|
||||
if (collapse) return;
|
||||
try {
|
||||
const value = JSON.parse(invoke(() => session.fetch_interface(reference.scope, reference.name))) as Fetched;
|
||||
@@ -274,11 +262,12 @@ export function setupProtocol(stage: HTMLElement, objects: readonly TreeNode[],
|
||||
fetched = value;
|
||||
button.textContent = `v ${reference.scope}::${reference.name}`;
|
||||
button.setAttribute('aria-expanded', 'true');
|
||||
view.textContent = value.textView; view.hidden = false;
|
||||
operations.replaceChildren(new Option('Operationを選択', ''));
|
||||
for (const operation of value.descriptor.operations) operations.add(new Option(operation.name, operation.name));
|
||||
operationControls.hidden = false;
|
||||
operations.disabled = false; args.disabled = false;
|
||||
renderTextView(view, value); revealTextViewLines(view);
|
||||
if (!attachOperationControls(view, value.descriptor.operations,
|
||||
(operation, argumentsJSON, form) => call(value, operation, argumentsJSON, form), resetResult)) {
|
||||
showStatus('Operationの宣言位置を確認できないため実行欄を表示できません。再読み込みしてください。');
|
||||
}
|
||||
view.hidden = false;
|
||||
stats(value.stats);
|
||||
} catch (error) {
|
||||
// A failure is still unknown, not a successfully acquired disclosure.
|
||||
@@ -309,18 +298,18 @@ export function setupProtocol(stage: HTMLElement, objects: readonly TreeNode[],
|
||||
if (client) run(observe);
|
||||
}
|
||||
}, { signal });
|
||||
callButton.addEventListener('click', () => run((session) => {
|
||||
const call = (target: Fetched, operation: Operation, argumentsJSON: () => string, form: HTMLFormElement) => run((session) => {
|
||||
if (fetched !== target || selectedPath !== observedPath || !form.isConnected || form.closest('[inert]')) return;
|
||||
resetResult();
|
||||
if (!fetched || !operations.value || selectedPath !== observedPath) throw new Error('Object/Interface/Operationを選択してください。');
|
||||
const ref = fetched.reference;
|
||||
const called: Called = JSON.parse(invoke(() => session.call_operation(selectedPath, ref.scope, ref.name, operations.value, args.value)));
|
||||
result.textContent = JSON.stringify(called, null, 2); result.hidden = false;
|
||||
const ref = target.reference;
|
||||
const called: Called = JSON.parse(invoke(() => session.call_operation(selectedPath, ref.scope, ref.name, operation.name, argumentsJSON())));
|
||||
renderJson(result, JSON.stringify(called, null, 2)); result.hidden = false;
|
||||
stats(called.stats);
|
||||
if (isPlaygroundPath(selectedPath)) {
|
||||
try {
|
||||
const parent = observePlayground(session);
|
||||
const observed: Observed = selectedPath === '/playground' ? parent : JSON.parse(invoke(() => session.observe(selectedPath, 1)));
|
||||
objectView.textContent = observed.textView; objectView.hidden = false;
|
||||
renderTextView(objectView, observed); objectView.hidden = false;
|
||||
observedPath = observed.path;
|
||||
stats(observed.stats);
|
||||
} catch (error) {
|
||||
@@ -332,7 +321,7 @@ export function setupProtocol(stage: HTMLElement, objects: readonly TreeNode[],
|
||||
showStatus(`Operationは成功しましたが再Observeに失敗しました: ${message(error)}`);
|
||||
}
|
||||
}
|
||||
}), { signal });
|
||||
});
|
||||
signal.addEventListener('abort', () => { generation++; }, { once: true });
|
||||
showMode();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
export interface TextViewToken { kind: string; text: string; }
|
||||
export interface TextView { textView: string; textViewTokens?: readonly TextViewToken[]; }
|
||||
|
||||
// Only semantic roles from wip-text-view may select a style. Layout and unknown
|
||||
// future kinds remain plain text, never HTML, selectors or operation identities.
|
||||
const highlighted = new Set([
|
||||
'keyword', 'type-name', 'operation-name', 'parameter-name', 'field-name',
|
||||
'case-name', 'string', 'reference', 'documentation', 'symbol',
|
||||
]);
|
||||
|
||||
export function renderTextView(view: HTMLElement, { textView, textViewTokens }: TextView) {
|
||||
// A missing/mismatched asset must not change the signature being displayed.
|
||||
if (!Array.isArray(textViewTokens) || textViewTokens.some(token => !token
|
||||
|| typeof token.text !== 'string' || typeof token.kind !== 'string')
|
||||
|| textViewTokens.map(token => token.text).join('') !== textView) {
|
||||
view.textContent = textView;
|
||||
return;
|
||||
}
|
||||
const fragment = document.createDocumentFragment();
|
||||
for (const token of textViewTokens) {
|
||||
if (highlighted.has(token.kind)) {
|
||||
const span = document.createElement('span');
|
||||
span.dataset.textKind = token.kind;
|
||||
span.textContent = token.text;
|
||||
fragment.append(span);
|
||||
} else fragment.append(document.createTextNode(token.text));
|
||||
}
|
||||
view.replaceChildren(fragment);
|
||||
}
|
||||
|
||||
const LINE_DURATION_MS = 10;
|
||||
|
||||
/** Shared by Interface text and the logical rows of an inline call form. */
|
||||
export function animateTextViewLines(lines: readonly HTMLElement[]) {
|
||||
lines.forEach((element, index) => {
|
||||
element.classList.add('text-view-line');
|
||||
element.style.setProperty('--line-duration', `${LINE_DURATION_MS}ms`);
|
||||
element.style.setProperty('--line-delay', `${index * LINE_DURATION_MS}ms`);
|
||||
});
|
||||
return Math.max(1, lines.length) * LINE_DURATION_MS;
|
||||
}
|
||||
|
||||
/** Expand in normal flow and fade logical lines, preserving text and styling. */
|
||||
export function revealTextViewLines(view: HTMLElement) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
const lines: HTMLSpanElement[] = [];
|
||||
let line = document.createElement('span');
|
||||
const flush = () => {
|
||||
if (!line.hasChildNodes()) return;
|
||||
lines.push(line); fragment.append(line);
|
||||
line = document.createElement('span');
|
||||
};
|
||||
// renderTextView emits only text nodes and flat token spans. Splitting also
|
||||
// handles plain-text fallback; WIP escapes and Unicode are never reinterpreted.
|
||||
for (const node of [...view.childNodes]) {
|
||||
const parts = (node.textContent ?? '').split('\n');
|
||||
parts.forEach((text, index) => {
|
||||
if (index) { flush(); fragment.append(document.createTextNode('\n')); }
|
||||
if (!text) return;
|
||||
const part = node.cloneNode(false);
|
||||
part.textContent = text; line.append(part);
|
||||
});
|
||||
}
|
||||
flush();
|
||||
const duration = animateTextViewLines(lines);
|
||||
// Animate the intrinsic height, not a guessed max-height: following rows move
|
||||
// with the reveal, including wrapped lines. Fresh wrappers also restart on reopen.
|
||||
const wrapperTag = view.tagName === 'PRE' ? 'span' : 'div';
|
||||
const reveal = document.createElement(wrapperTag); reveal.className = 'text-view-reveal';
|
||||
reveal.style.setProperty('--reveal-duration', `${duration}ms`);
|
||||
const content = document.createElement(wrapperTag); content.className = 'text-view-lines';
|
||||
content.append(fragment); reveal.append(content);
|
||||
view.replaceChildren(reveal);
|
||||
}
|
||||
|
||||
/** Close from the current rendered height, even if the reveal is still running. */
|
||||
export function collapseTextView(view: HTMLElement) {
|
||||
const reveal = view.firstElementChild as HTMLElement | null;
|
||||
const finish = () => {
|
||||
// A reopen or Interface switch owns a new wrapper; never clear that content.
|
||||
if (view.firstElementChild !== reveal) return;
|
||||
view.hidden = true; view.inert = false; view.replaceChildren();
|
||||
};
|
||||
if (!reveal) { finish(); return; }
|
||||
reveal.style.setProperty('--collapse-height', `${reveal.getBoundingClientRect().height}px`);
|
||||
const lines = [...reveal.querySelectorAll<HTMLElement>('.text-view-line')];
|
||||
reveal.style.setProperty('--reveal-duration', `${Math.max(1, lines.length) * LINE_DURATION_MS}ms`);
|
||||
lines.forEach((line, index) => {
|
||||
line.style.setProperty('--collapse-opacity', getComputedStyle(line).opacity);
|
||||
line.style.setProperty('--line-close-delay', `${(lines.length - index - 1) * LINE_DURATION_MS}ms`);
|
||||
});
|
||||
view.inert = true;
|
||||
reveal.classList.add('text-view-closing');
|
||||
const animation = reveal.getAnimations()[0];
|
||||
// No animation under reduced-motion/print. Live preference changes cancel the
|
||||
// CSS animation and settle the same cleanup, without a timer or stale listener.
|
||||
if (animation) void animation.finished.then(finish, finish);
|
||||
else finish();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/* Kirari's JSON token classes, scoped to responses. Keep the existing pre layout. */
|
||||
.protocol-json {
|
||||
--kirari-property: #205f9c;
|
||||
--kirari-string: #2d6841;
|
||||
--kirari-number: #8a4b18;
|
||||
--kirari-boolean: #713f9c;
|
||||
--kirari-null: #62685c;
|
||||
}
|
||||
.protocol-json .kirari-property { color: var(--kirari-property); }
|
||||
.protocol-json .kirari-string { color: var(--kirari-string); }
|
||||
.protocol-json .kirari-number { color: var(--kirari-number); }
|
||||
.protocol-json .kirari-boolean { color: var(--kirari-boolean); }
|
||||
.protocol-json .kirari-null { color: var(--kirari-null); }
|
||||
.protocol-json .kirari-punctuation { color: var(--ink); }
|
||||
|
||||
@media screen and (prefers-color-scheme: dark) {
|
||||
.protocol-json {
|
||||
--kirari-property: #90bff0;
|
||||
--kirari-string: #acd699;
|
||||
--kirari-number: #e6b580;
|
||||
--kirari-boolean: #d6a6f0;
|
||||
--kirari-null: #a7b09e;
|
||||
}
|
||||
}
|
||||
@@ -3,17 +3,26 @@
|
||||
.mode-switch[hidden], .protocol-panel[hidden], .protocol-panel [hidden] { display: none; }
|
||||
.mode-switch button, .protocol-panel button { border: 1px solid var(--line); background: var(--surface); color: var(--ink); padding: 0 1ch; cursor: pointer; }
|
||||
.mode-switch button[aria-pressed="true"] { background: var(--accent); color: var(--node-selected-ink); }
|
||||
.mode-switch button:focus-visible, .protocol-panel :is(button, select, textarea, pre):focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
||||
.mode-switch button:focus-visible, .protocol-panel :is(button, input, select, textarea, pre, .protocol-interface-view):focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
||||
.protocol-panel { margin-top: calc(2 * var(--row)); max-height: calc(100svh - 8 * var(--row)); overflow: auto; padding: 0 1ch 1ch; color: var(--ink); font: 14px/1.6 var(--mono); }
|
||||
.protocol-panel label { display: block; margin-bottom: 1ch; }
|
||||
.protocol-panel :is(select, textarea) { display: block; width: 100%; min-width: 0; box-sizing: border-box; font: inherit; color: var(--ink); background: var(--surface); border: 1px solid var(--line); border-radius: 0; padding: .3ch .5ch; }
|
||||
.protocol-panel textarea { resize: vertical; }
|
||||
.protocol-panel :disabled { opacity: .6; cursor: not-allowed; }
|
||||
.protocol-panel pre { box-sizing: border-box; max-width: 100%; margin: 0; padding: 0; border: 0; white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; background: transparent; color: inherit; }
|
||||
.protocol-operation, .protocol-panel [data-protocol-result] { margin-top: 1ch; }
|
||||
.protocol-panel pre, .protocol-interface-view { box-sizing: border-box; max-width: 100%; margin: 0; padding: 0; border: 0; white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; background: transparent; color: inherit; }
|
||||
.protocol-panel [data-protocol-result] { margin-top: 1ch; }
|
||||
.protocol-operation { margin-inline-start: 2ch; white-space: normal; user-select: none; }
|
||||
.protocol-operation button { font: inherit; border: 0; border-radius: 0; background: var(--code-bg); color: var(--code-ink); padding: 0 .5ch; }
|
||||
.protocol-operation button:is(:hover, :focus-visible) { background: var(--accent); color: var(--node-selected-ink); }
|
||||
.protocol-call-form { margin: 0; padding: .3ch; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.protocol-argument { padding-inline-start: 2ch; display: flex; flex-wrap: wrap; gap: .5ch; }
|
||||
.protocol-panel .protocol-argument label { display: flex; align-items: baseline; gap: .3ch; margin: 0; min-width: 0; max-width: 100%; }
|
||||
.protocol-argument label > span { overflow-wrap: anywhere; }
|
||||
.protocol-panel .protocol-argument :is(input:not([type="checkbox"]), select, textarea) { display: inline-block; width: 16ch; min-width: 0; max-width: 100%; box-sizing: border-box; font: inherit; background: var(--surface); color: var(--ink); border: 0; border-bottom: 1px solid var(--line); border-radius: 0; padding: 0 .3ch; user-select: text; }
|
||||
.protocol-argument .protocol-optional { color: var(--muted); font-size: .85em; }
|
||||
.protocol-interface { margin-block: .5ch; }
|
||||
.protocol-panel .protocol-interface > button { border: 0; background: transparent; padding: .25ch .5ch; max-width: 100%; text-align: left; overflow-wrap: anywhere; }
|
||||
.protocol-interface > button:hover { background: var(--node-hover); }
|
||||
.protocol-panel .protocol-interface > button { border: 0; border-radius: 0; background: var(--code-bg); color: var(--code-ink); padding: 0 .5ch; font: inherit; max-width: 100%; text-align: left; overflow-wrap: anywhere; }
|
||||
.protocol-panel .protocol-interface > button:is(:hover, :focus-visible) { background: var(--accent); color: var(--node-selected-ink); }
|
||||
[data-protocol-status] { margin: 0; color: var(--muted); overflow-wrap: anywhere; }
|
||||
@media (max-width: 800px) { .protocol-panel { max-height: none; margin-top: var(--row); padding-inline: 0; } }
|
||||
@media print { .mode-switch { display: none; } .protocol-panel { max-height: none; overflow: visible; } }
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/* Token roles come from Rust's structured Text View, not a client-side lexer. */
|
||||
.protocol-signature {
|
||||
--tv-keyword: #713f9c;
|
||||
--tv-type: #14656f;
|
||||
--tv-operation: #205f9c;
|
||||
--tv-member: #8a4b18;
|
||||
--tv-string: #2d6841;
|
||||
--tv-reference: #0c6c78;
|
||||
--tv-documentation: #62685c;
|
||||
}
|
||||
.protocol-signature [data-text-kind="keyword"] { color: var(--tv-keyword); }
|
||||
.protocol-signature [data-text-kind="type-name"] { color: var(--tv-type); }
|
||||
.protocol-signature [data-text-kind="operation-name"] { color: var(--tv-operation); }
|
||||
.protocol-signature :is([data-text-kind="parameter-name"], [data-text-kind="field-name"], [data-text-kind="case-name"]) { color: var(--tv-member); }
|
||||
.protocol-signature [data-text-kind="string"] { color: var(--tv-string); }
|
||||
.protocol-signature [data-text-kind="reference"] { color: var(--tv-reference); }
|
||||
.protocol-signature [data-text-kind="documentation"] { color: var(--tv-documentation); }
|
||||
.protocol-signature [data-text-kind="symbol"] { color: var(--ink); }
|
||||
.protocol-signature .text-view-reveal {
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
animation: text-view-expand var(--reveal-duration) ease-out backwards;
|
||||
}
|
||||
.protocol-signature .text-view-lines {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
@keyframes text-view-expand {
|
||||
from { grid-template-rows: 0fr; }
|
||||
to { grid-template-rows: 1fr; }
|
||||
}
|
||||
.protocol-signature .text-view-line {
|
||||
animation: text-view-line-in var(--line-duration) ease-out var(--line-delay) backwards;
|
||||
}
|
||||
@keyframes text-view-line-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
.protocol-signature .text-view-closing {
|
||||
overflow: hidden;
|
||||
animation: text-view-collapse var(--reveal-duration) ease-out both;
|
||||
}
|
||||
.protocol-signature .text-view-closing .text-view-line {
|
||||
animation: text-view-line-out var(--line-duration) ease-out var(--line-close-delay) both;
|
||||
}
|
||||
@keyframes text-view-collapse {
|
||||
from { height: var(--collapse-height); }
|
||||
to { height: 0; }
|
||||
}
|
||||
@keyframes text-view-line-out {
|
||||
from { opacity: var(--collapse-opacity); }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce), print {
|
||||
.protocol-signature :is(.text-view-reveal, .text-view-line),
|
||||
.protocol-signature .text-view-closing .text-view-line { animation: none; }
|
||||
}
|
||||
|
||||
/* Colors only: text, spacing, font metrics and copied content stay unchanged.
|
||||
Print uses the light palette even when the screen theme is dark. */
|
||||
@media screen and (prefers-color-scheme: dark) {
|
||||
.protocol-signature {
|
||||
--tv-keyword: #d6a6f0;
|
||||
--tv-type: #82d2ca;
|
||||
--tv-operation: #90bff0;
|
||||
--tv-member: #e6b580;
|
||||
--tv-string: #acd699;
|
||||
--tv-reference: #84cedd;
|
||||
--tv-documentation: #a7b09e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
import { tokenize } from '@vortexlabs/kirari/core';
|
||||
import { json } from '@vortexlabs/kirari/languages/json';
|
||||
|
||||
test.use({ javaScriptEnabled: true, reducedMotion: 'reduce' });
|
||||
const result = (page: Page) => page.getByLabel('Operation result', { exact: true });
|
||||
async function readDocument(page: Page, base: string) {
|
||||
await page.goto(new URL('?mode=protocol', base).href);
|
||||
await page.getByRole('button', { name: '? /::document', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Run read', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Execute read', exact: true }).click();
|
||||
await expect(result(page)).toBeVisible();
|
||||
}
|
||||
|
||||
for (const scheme of ['light', 'dark'] as const) {
|
||||
test(`Kirari JSON tokens preserve response text and use readable ${scheme} colors`, async ({ page }, info) => {
|
||||
await page.emulateMedia({ colorScheme: scheme });
|
||||
await page.route('**/wip-wasm/wip_browser.js?instance=protocol', async route => {
|
||||
const response = await route.fetch();
|
||||
// Preserve the real call and attach a display-only fixture for JSON kinds
|
||||
// not emitted by this site's current Operations (booleans and null).
|
||||
await route.fulfill({ response, body: `${await response.text()}\nconst original = BrowserSession.prototype.call_operation; BrowserSession.prototype.call_operation = function(...args) { const value = JSON.parse(original.apply(this,args)); value.preview = { enabled: true, disabled: false, empty: null, fraction: -1.25e-8, nested: [1, { 'escaped"key': '日本語 🐈 \\" <img src=x onerror="window.__jsonExecuted=1"> & \\n\\t' }] }; window.__jsonResponse = value; return JSON.stringify(value); };` });
|
||||
});
|
||||
await readDocument(page, info.project.use.baseURL!);
|
||||
const source = await page.evaluate(() => JSON.stringify((window as unknown as { __jsonResponse: unknown }).__jsonResponse, null, 2));
|
||||
const view = result(page);
|
||||
expect(await view.textContent()).toBe(source);
|
||||
expect(await view.innerText()).toBe(source);
|
||||
const expected = tokenize(source, json).filter(token => token.type !== 'text').map(token => ({ className: `kirari-${token.type}`, text: token.value }));
|
||||
expect(await view.locator('span').evaluateAll(elements => elements.map(element => ({ className: element.className, text: element.textContent })))).toEqual(expected);
|
||||
for (const type of ['property', 'string', 'number', 'boolean', 'null', 'punctuation']) await expect(view.locator(`.kirari-${type}`).first()).toBeVisible();
|
||||
await expect(view.locator('img, script, a, style, iframe')).toHaveCount(0);
|
||||
expect(await page.evaluate(() => '__jsonExecuted' in window)).toBe(false);
|
||||
const selected = await view.evaluate(element => {
|
||||
const range = document.createRange(); range.selectNodeContents(element);
|
||||
const selection = window.getSelection()!; selection.removeAllRanges(); selection.addRange(range);
|
||||
const text = selection.toString(); selection.removeAllRanges(); return text;
|
||||
});
|
||||
expect(selected).toBe(source);
|
||||
const palette = await view.evaluate(element => {
|
||||
const probe = document.createElement('span'); probe.style.backgroundColor = 'var(--paper)'; element.append(probe);
|
||||
const background = getComputedStyle(probe).backgroundColor; probe.remove();
|
||||
return { background, colors: [...element.querySelectorAll('span')].map(span => getComputedStyle(span).color) };
|
||||
});
|
||||
const luminance = (color: string) => {
|
||||
const rgb = color.match(/[\d.]+/g)!.slice(0, 3).map(Number).map(value => value / 255).map(value => value <= .04045 ? value / 12.92 : ((value + .055) / 1.055) ** 2.4);
|
||||
return .2126 * rgb[0] + .7152 * rgb[1] + .0722 * rgb[2];
|
||||
};
|
||||
expect(new Set(palette.colors).size).toBe(6);
|
||||
for (const color of palette.colors) {
|
||||
const a = luminance(color), b = luminance(palette.background);
|
||||
expect((Math.max(a, b) + .05) / (Math.min(a, b) + .05)).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
const key = view.locator('.kirari-property').first();
|
||||
const before = await key.evaluate(element => getComputedStyle(element).color);
|
||||
await page.emulateMedia({ colorScheme: scheme === 'light' ? 'dark' : 'light' });
|
||||
await expect(key).not.toHaveCSS('color', before);
|
||||
expect(await view.textContent()).toBe(source);
|
||||
await page.emulateMedia({ colorScheme: scheme });
|
||||
await page.screenshot({ path: info.outputPath(`json-${scheme}.png`), fullPage: true });
|
||||
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '1');
|
||||
await page.getByRole('button', { name: 'Run read', exact: true }).click();
|
||||
await expect(view).toBeHidden(); await expect(view.locator('span')).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
|
||||
test('real response markup stays literal and each new result replaces the old highlighted JSON', async ({ page }, info) => {
|
||||
await page.route('**/worldspace.json', async route => {
|
||||
const response = await route.fetch(); const data = await response.json();
|
||||
data.nodes[0].html = '<script>window.__jsonExecuted=1</script><img src=x onerror="window.__jsonExecuted=1">日本語 🐈 & " \\';
|
||||
await route.fulfill({ response, json: data });
|
||||
});
|
||||
await readDocument(page, info.project.use.baseURL!);
|
||||
const view = result(page);
|
||||
await expect(view.locator('.kirari-string').filter({ hasText: '<script>' })).toHaveCount(1);
|
||||
await expect(view.locator('script, img')).toHaveCount(0);
|
||||
expect(await page.evaluate(() => '__jsonExecuted' in window)).toBe(false);
|
||||
await page.locator('.demo-node[data-path="/playground"]').click();
|
||||
await page.locator('.demo-node[data-path="/playground/apple"]').click();
|
||||
await expect(view).toBeHidden(); await expect(view.locator('span')).toHaveCount(0);
|
||||
await page.getByRole('button', { name: '? /::counter', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Run increment', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Execute increment', exact: true }).click();
|
||||
await expect(view.locator('.kirari-string').filter({ hasText: '<script>' })).toHaveCount(0);
|
||||
const called = JSON.parse((await view.textContent())!);
|
||||
expect(called.result).toEqual({ kind: 'integer', value: '4' });
|
||||
expect(called.stats.calls).toBe(2);
|
||||
await expect(view.locator('.kirari-number')).toHaveCount(3); // calls, interfaces, observes
|
||||
await expect(page.locator('.demo-node[data-path="/playground/apple"]')).toHaveText('apple');
|
||||
await expect(page.locator('.node-value')).toHaveCount(0);
|
||||
await page.getByLabel('increment.amount', { exact: true }).fill('2');
|
||||
await expect(view).toBeHidden(); await expect(view.locator('span')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('a highlighting failure keeps the successful result as text without replaying the call', async ({ page }, info) => {
|
||||
await page.goto(new URL('playground/?mode=protocol&object=%2Fplayground%2Fapple', info.project.use.baseURL!).href);
|
||||
await page.getByRole('button', { name: '? /::counter', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Run increment', exact: true }).click();
|
||||
await page.evaluate(() => {
|
||||
const original = document.createDocumentFragment.bind(document);
|
||||
document.createDocumentFragment = () => { document.createDocumentFragment = original; throw new Error('presentation unavailable'); };
|
||||
});
|
||||
await page.getByRole('button', { name: 'Execute increment', exact: true }).click();
|
||||
const view = result(page);
|
||||
await expect(view).toBeVisible(); await expect(view.locator('span')).toHaveCount(0);
|
||||
expect(JSON.parse((await view.textContent())!).result).toEqual({ kind: 'integer', value: '4' });
|
||||
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '1');
|
||||
await expect(page.locator('.demo-node[data-path="/playground/apple"]')).toHaveText('apple');
|
||||
await expect(page.locator('.node-value')).toHaveCount(0);
|
||||
await expect(page.locator('[data-protocol-status]')).toBeHidden();
|
||||
await page.getByRole('button', { name: 'Execute increment', exact: true }).click();
|
||||
await expect(view.locator('.kirari-property').first()).toBeVisible();
|
||||
expect(JSON.parse((await view.textContent())!).result).toEqual({ kind: 'integer', value: '5' });
|
||||
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '2');
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
test.use({ javaScriptEnabled: true, reducedMotion: 'reduce' });
|
||||
const form = (page: Page, name: string) => page.getByRole('form', { name: `${name} arguments`, exact: true });
|
||||
async function collection(page: Page, base: string) {
|
||||
await page.goto(new URL('playground/?mode=protocol', base).href);
|
||||
await page.getByRole('button', { name: '? /::collection', exact: true }).click();
|
||||
}
|
||||
|
||||
test('Run belongs between each Operation comment and declaration; inline arguments call the real Host', async ({ page }, info) => {
|
||||
await collection(page, info.project.use.baseURL!);
|
||||
await expect(page.locator('[data-protocol-operation], [data-protocol-operation-controls], [data-protocol-arguments]')).toHaveCount(0);
|
||||
const positions = await page.locator('[data-operation-control]').evaluateAll(controls => controls.map(control => ({
|
||||
operation: (control as HTMLElement).dataset.operationControl,
|
||||
previous: control.previousElementSibling?.textContent,
|
||||
next: control.nextElementSibling?.textContent,
|
||||
inInterface: Boolean(control.closest('[data-interface-view]')),
|
||||
})));
|
||||
expect(positions.map(position => position.operation)).toEqual(['create', 'remove']);
|
||||
for (const position of positions) {
|
||||
expect(position.previous?.trim()).toMatch(/^\/\/\//);
|
||||
expect(position.next?.trim()).toBe(`operation ${position.operation}(`);
|
||||
expect(position.inInterface).toBe(true);
|
||||
}
|
||||
await expect(page.locator('[data-operation-form], [data-protocol-call]')).toHaveCount(0);
|
||||
await page.getByRole('button', { name: 'Run create', exact: true }).focus();
|
||||
await page.keyboard.press('Enter');
|
||||
const create = form(page, 'create');
|
||||
await expect(create).toBeVisible();
|
||||
await expect(create.getByLabel('create.name', { exact: true })).toBeFocused();
|
||||
expect(await create.textContent()).toBe('create(name:"",) Execute');
|
||||
expect(await create.evaluate(element => element.closest('[data-interface-view]')!.getAnimations({ subtree: true }).length)).toBe(0);
|
||||
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '0');
|
||||
await create.getByLabel('create.name', { exact: true }).fill('banana');
|
||||
await page.screenshot({ path: info.outputPath('inline-create.png'), fullPage: true });
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page.locator('.demo-node[data-path="/playground/banana"]')).toHaveText('banana');
|
||||
await expect(page.locator('.node-value')).toHaveCount(0);
|
||||
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '1');
|
||||
await expect(page.getByLabel('Operation result')).toContainText('create');
|
||||
await page.getByRole('button', { name: 'Run remove', exact: true }).click();
|
||||
await expect(create).toHaveCount(0);
|
||||
await expect(page.getByLabel('Operation result')).toBeHidden();
|
||||
await form(page, 'remove').getByLabel('remove.name', { exact: true }).fill('banana');
|
||||
await page.getByRole('button', { name: 'Execute remove', exact: true }).click();
|
||||
await expect(page.locator('.demo-node[data-path="/playground/banana"]')).toHaveCount(0);
|
||||
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '2');
|
||||
await form(page, 'remove').getByLabel('remove.name', { exact: true }).press('Escape');
|
||||
await expect(form(page, 'remove')).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: 'Run remove', exact: true })).toBeFocused();
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||||
});
|
||||
|
||||
test('inline call expansion and collapse move the declaration at 10ms per line without stale cleanup', async ({ page }, info) => {
|
||||
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
||||
await collection(page, info.project.use.baseURL!);
|
||||
const toggle = page.getByRole('button', { name: 'Run create', exact: true });
|
||||
const frames = await toggle.evaluate(button => {
|
||||
button.closest('[data-interface-view]')!.getAnimations({ subtree: true }).forEach(animation => animation.finish());
|
||||
const declaration = button.parentElement!.nextElementSibling!;
|
||||
const body = button.nextElementSibling as HTMLElement;
|
||||
const offset = () => declaration.getBoundingClientRect().top - button.getBoundingClientRect().top;
|
||||
const before = offset();
|
||||
(button as HTMLButtonElement).click();
|
||||
const animation = body.firstElementChild!.getAnimations()[0];
|
||||
const duration = Number(animation.effect!.getComputedTiming().duration);
|
||||
const animations = body.getAnimations({ subtree: true });
|
||||
animations.forEach(animation => animation.pause());
|
||||
const opening = [0, .5, 1].map(progress => {
|
||||
animations.forEach(animation => { animation.currentTime = progress * duration; });
|
||||
return { height: body.getBoundingClientRect().height, offset: offset() };
|
||||
});
|
||||
animations.forEach(animation => animation.finish());
|
||||
(button as HTMLButtonElement).click();
|
||||
const closingAnimation = body.firstElementChild!.getAnimations()[0];
|
||||
const closingAnimations = body.getAnimations({ subtree: true });
|
||||
closingAnimations.forEach(animation => animation.pause());
|
||||
const closing = [0, .5, 1].map(progress => {
|
||||
closingAnimations.forEach(animation => { animation.currentTime = progress * duration; });
|
||||
return { height: body.getBoundingClientRect().height, offset: offset() };
|
||||
});
|
||||
closingAnimations.forEach(animation => animation.finish());
|
||||
return { before, opening, closing, duration: animation.effect!.getComputedTiming().duration, closeDuration: closingAnimation.effect!.getComputedTiming().duration };
|
||||
});
|
||||
expect(frames.duration).toBe(30); expect(frames.closeDuration).toBe(30);
|
||||
expect(frames.opening[0].height).toBe(0);
|
||||
expect(frames.opening[1].height).toBeGreaterThan(0);
|
||||
expect(frames.opening[2].height).toBeGreaterThan(frames.opening[1].height);
|
||||
expect(frames.closing[1].height).toBeLessThan(frames.closing[0].height);
|
||||
expect(frames.closing[2].height).toBe(0);
|
||||
for (const frame of [...frames.opening, ...frames.closing]) expect(frame.offset - frames.before).toBeCloseTo(frame.height, 1);
|
||||
await expect(form(page, 'create')).toHaveCount(0);
|
||||
await toggle.evaluate(async button => {
|
||||
const click = () => (button as HTMLButtonElement).click();
|
||||
click(); click();
|
||||
const body = button.nextElementSibling!;
|
||||
const old = body.firstElementChild!.getAnimations()[0];
|
||||
old.pause(); old.currentTime = 5;
|
||||
const settled = old.finished.catch(() => {});
|
||||
click(); body.getAnimations({ subtree: true }).forEach(animation => animation.finish());
|
||||
await settled;
|
||||
});
|
||||
await expect(form(page, 'create')).toBeVisible();
|
||||
await expect(form(page, 'create').getByLabel('create.name', { exact: true })).toBeEditable();
|
||||
await page.evaluate(() => {
|
||||
const create = document.querySelector<HTMLFormElement>('[data-operation-form="create"]')!;
|
||||
create.querySelector('input')!.value = 'must_not_run';
|
||||
document.querySelector<HTMLButtonElement>('[data-operation-control="create"] > button')!.click();
|
||||
document.querySelector<HTMLButtonElement>('[data-operation-control="remove"] > button')!.click();
|
||||
create.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||
});
|
||||
await expect(page.locator('[data-operation-form]')).toHaveCount(1);
|
||||
await expect(form(page, 'remove')).toBeVisible();
|
||||
await page.evaluate(() => {
|
||||
const remove = document.querySelector<HTMLFormElement>('[data-operation-form="remove"]')!;
|
||||
remove.querySelector('input')!.value = 'apple';
|
||||
document.querySelector<HTMLButtonElement>('[data-interface-toggle][aria-expanded="true"]')!.click();
|
||||
remove.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||
});
|
||||
await expect(page.locator('[data-operation-control], [data-operation-form]')).toHaveCount(0);
|
||||
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '0');
|
||||
});
|
||||
|
||||
test('Run rows share Interface fades: 10ms each, top-down open and bottom-up close', async ({ page }, info) => {
|
||||
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
||||
await page.goto(new URL('playground/?mode=protocol', info.project.use.baseURL!).href);
|
||||
for (const [iface, operation, count] of [['collection', 'create', 3], ['document', 'read', 1]] as const) {
|
||||
await page.getByRole('button', { name: `? /::${iface}`, exact: true }).click();
|
||||
const measured = await page.getByRole('button', { name: `Run ${operation}`, exact: true }).evaluate(button => {
|
||||
button.closest('[data-interface-view]')!.getAnimations({ subtree: true }).forEach(animation => animation.finish());
|
||||
(button as HTMLButtonElement).click();
|
||||
const body = button.nextElementSibling!;
|
||||
const lines = [...body.querySelectorAll('.text-view-line')];
|
||||
const timings = () => lines.map(line => {
|
||||
const animation = line.getAnimations()[0];
|
||||
return { name: (animation as CSSAnimation).animationName, ...animation.effect!.getTiming() };
|
||||
});
|
||||
const opacity = () => lines.map(line => Number(getComputedStyle(line).opacity));
|
||||
const animations = body.getAnimations({ subtree: true });
|
||||
animations.forEach(animation => { animation.pause(); animation.currentTime = 5; });
|
||||
const opening = { timing: timings(), opacity: opacity() };
|
||||
const duration = body.firstElementChild!.getAnimations()[0].effect!.getComputedTiming().duration;
|
||||
animations.forEach(animation => animation.finish());
|
||||
(button as HTMLButtonElement).click();
|
||||
const closingAnimations = body.getAnimations({ subtree: true });
|
||||
closingAnimations.forEach(animation => { animation.pause(); animation.currentTime = 5; });
|
||||
const closing = { timing: timings(), opacity: opacity() };
|
||||
closingAnimations.forEach(animation => animation.finish());
|
||||
return { opening, closing, duration };
|
||||
});
|
||||
expect(measured.duration).toBe(count * 10);
|
||||
expect(measured.opening.timing).toHaveLength(count);
|
||||
measured.opening.timing.forEach((timing, index) => {
|
||||
expect(timing.name).toBe('text-view-line-in');
|
||||
expect(timing.duration).toBe(10); expect(timing.delay).toBe(index * 10);
|
||||
});
|
||||
expect(measured.opening.opacity[0]).toBeGreaterThan(0);
|
||||
expect(measured.opening.opacity[0]).toBeLessThan(1);
|
||||
expect(measured.opening.opacity.slice(1)).toEqual(Array(count - 1).fill(0));
|
||||
measured.closing.timing.forEach((timing, index) => {
|
||||
expect(timing.name).toBe('text-view-line-out');
|
||||
expect(timing.duration).toBe(10); expect(timing.delay).toBe((count - index - 1) * 10);
|
||||
});
|
||||
expect(measured.closing.opacity.at(-1)).toBeGreaterThan(0);
|
||||
expect(measured.closing.opacity.at(-1)).toBeLessThan(1);
|
||||
expect(measured.closing.opacity.slice(0, -1)).toEqual(Array(count - 1).fill(1));
|
||||
await expect(form(page, operation)).toHaveCount(0);
|
||||
}
|
||||
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '0');
|
||||
});
|
||||
|
||||
test('Run line fades settle on live reduced-motion and print changes', async ({ page }, info) => {
|
||||
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
||||
await collection(page, info.project.use.baseURL!);
|
||||
const toggle = page.getByRole('button', { name: 'Run create', exact: true });
|
||||
await toggle.evaluate(button => {
|
||||
button.closest('[data-interface-view]')!.getAnimations({ subtree: true }).forEach(animation => animation.finish());
|
||||
(button as HTMLButtonElement).click();
|
||||
button.nextElementSibling!.getAnimations({ subtree: true }).forEach(animation => { animation.pause(); animation.currentTime = 5; });
|
||||
});
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
const create = form(page, 'create');
|
||||
await expect(create).toBeVisible();
|
||||
expect(await create.evaluate(element => [...element.querySelectorAll('.text-view-line')].map(line => getComputedStyle(line).opacity))).toEqual(['1', '1', '1']);
|
||||
expect(await create.evaluate(element => element.getAnimations({ subtree: true }).length)).toBe(0);
|
||||
await toggle.click(); await expect(create).toHaveCount(0);
|
||||
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
||||
await toggle.evaluate(button => {
|
||||
(button as HTMLButtonElement).click();
|
||||
const body = button.nextElementSibling!;
|
||||
body.getAnimations({ subtree: true }).forEach(animation => animation.finish());
|
||||
(button as HTMLButtonElement).click();
|
||||
body.getAnimations({ subtree: true }).forEach(animation => { animation.pause(); animation.currentTime = 5; });
|
||||
});
|
||||
await page.emulateMedia({ media: 'print' });
|
||||
await expect(create).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('integer inputs preserve decimal text and no-argument calls still require Execute', async ({ page }, info) => {
|
||||
await page.goto(new URL('playground/?mode=protocol&object=%2Fplayground%2Fapple', info.project.use.baseURL!).href);
|
||||
await page.getByRole('button', { name: '? /::counter', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Run increment', exact: true }).click();
|
||||
const amount = form(page, 'increment').getByLabel('increment.amount', { exact: true });
|
||||
for (const invalid of ['1.5', '9007199254740993', '1e2']) {
|
||||
await amount.fill(invalid);
|
||||
await page.getByRole('button', { name: 'Execute increment', exact: true }).click();
|
||||
await expect(page.locator('[data-protocol-status]')).toBeVisible();
|
||||
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '0');
|
||||
await expect(page.locator('.node-value')).toHaveCount(0);
|
||||
await expect(page.getByLabel('Operation result')).toBeHidden();
|
||||
}
|
||||
await amount.fill('-2'); await amount.press('Enter');
|
||||
expect(JSON.parse(await page.getByLabel('Operation result').innerText()).result).toEqual({ kind: 'integer', value: '1' });
|
||||
await expect(page.locator('.demo-node[data-path="/playground/apple"]')).toHaveText('apple');
|
||||
await page.getByRole('button', { name: 'Run get', exact: true }).click();
|
||||
await expect(form(page, 'get')).toHaveText('get() Execute');
|
||||
await expect(form(page, 'get').locator('input, textarea, select')).toHaveCount(0);
|
||||
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '1');
|
||||
await page.getByRole('button', { name: 'Execute get', exact: true }).click();
|
||||
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '2');
|
||||
expect(JSON.parse(await page.getByLabel('Operation result').innerText()).result).toEqual({ kind: 'integer', value: '1' });
|
||||
});
|
||||
|
||||
test('missing Interface tokens retain literal text without guessing execution targets', async ({ page }, info) => {
|
||||
await page.route('**/wip-wasm/wip_browser.js?instance=protocol', async route => {
|
||||
const response = await route.fetch();
|
||||
await route.fulfill({ response, body: `${await response.text()}\nconst original = BrowserSession.prototype.fetch_interface; BrowserSession.prototype.fetch_interface = function(...args) { const value = JSON.parse(original.apply(this,args)); delete value.textViewTokens; window.__plainInterface = value.textView; return JSON.stringify(value); };` });
|
||||
});
|
||||
await collection(page, info.project.use.baseURL!);
|
||||
expect(await page.locator('[data-interface-view]:visible').textContent()).toBe(await page.evaluate(() => (window as unknown as { __plainInterface: string }).__plainInterface));
|
||||
await expect(page.locator('[data-operation-control], [data-protocol-call]')).toHaveCount(0);
|
||||
await expect(page.locator('[data-protocol-status]')).toContainText('Operationの宣言位置を確認できない');
|
||||
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '0');
|
||||
});
|
||||
|
||||
test('Descriptor fields serialize optional and composite tagged Values without evaluating input', async ({ page }, info) => {
|
||||
// Synthetic DTO boundary only: capture arguments without calling the Host.
|
||||
await page.route('**/wip-wasm/wip_browser.js?instance=protocol', async route => {
|
||||
const response = await route.fetch();
|
||||
await route.fulfill({ response, body: `${await response.text()}\nconst original = BrowserSession.prototype.fetch_interface; BrowserSession.prototype.fetch_interface = function(...args) { const value = JSON.parse(original.apply(this,args)); if (args[1] === 'collection') value.descriptor.operations[0].parameters = [{name:'text',required:true,type:{kind:'string'}},{name:'enabled',required:true,type:{kind:'boolean'}},{name:'ratio',required:true,type:{kind:'number'}},{name:'nothing',required:true,type:{kind:'unit'}},{name:'items',required:true,type:{kind:'list'}},{name:'optional',required:false,type:{kind:'string'}}]; return JSON.stringify(value); }; BrowserSession.prototype.call_operation = function(...args) { window.__callInput = args; throw new Error('fixture captured'); };` });
|
||||
});
|
||||
await collection(page, info.project.use.baseURL!);
|
||||
await page.getByRole('button', { name: 'Run create', exact: true }).click();
|
||||
const create = form(page, 'create');
|
||||
const raw = '日本語 " \\ <img src=x onerror=alert(1)>';
|
||||
await create.getByLabel('create.text', { exact: true }).fill(raw);
|
||||
await create.getByLabel('create.enabled', { exact: true }).selectOption('true');
|
||||
await create.getByLabel('create.ratio', { exact: true }).fill('1.25');
|
||||
await create.getByLabel('create.items', { exact: true }).fill('{"kind":"list","value":[{"kind":"integer","value":"2"}]}');
|
||||
await expect(create.getByLabel('create.optional', { exact: true })).toBeDisabled();
|
||||
await create.getByRole('button', { name: 'Execute create', exact: true }).click();
|
||||
const argumentsJSON = () => page.evaluate(() => (window as unknown as { __callInput: string[] }).__callInput[4]);
|
||||
expect(JSON.parse(await argumentsJSON())).toEqual({ text: { kind: 'string', value: raw }, enabled: { kind: 'boolean', value: true }, ratio: { kind: 'number', value: 1.25 }, nothing: { kind: 'unit' }, items: { kind: 'list', value: [{ kind: 'integer', value: '2' }] } });
|
||||
await create.getByLabel('Include create.optional', { exact: true }).check();
|
||||
await create.getByRole('button', { name: 'Execute create', exact: true }).click();
|
||||
expect(JSON.parse(await argumentsJSON()).optional).toEqual({ kind: 'string', value: '' });
|
||||
await expect(page.locator('[data-protocol-panel] img, [data-protocol-panel] script')).toHaveCount(0);
|
||||
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '0');
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import { expect, test, type Page } from '@playwright/test';
|
||||
test.use({ javaScriptEnabled: true, reducedMotion: 'reduce' });
|
||||
const panel = (page: Page) => page.locator('[data-protocol-panel]');
|
||||
const node = (page: Page, name: string) => page.locator(`.demo-node[data-path="/playground${name ? `/${name}` : ''}"]`);
|
||||
const value = (page: Page, name: string) => node(page, name).locator('.node-value');
|
||||
const url = (base: string, child?: string) => {
|
||||
const result = new URL('playground/?mode=protocol', base);
|
||||
if (child) result.searchParams.set('object', `/playground/${child}`);
|
||||
@@ -14,13 +13,23 @@ async function ready(page: Page, path = '/playground') {
|
||||
await expect(panel(page)).toHaveAttribute('data-path', path);
|
||||
await expect(page.getByLabel('Object Text View')).toBeVisible();
|
||||
}
|
||||
async function execute(page: Page, iface: string, operation: string, args?: object) {
|
||||
async function openOperation(page: Page, operation: string) {
|
||||
const form = page.getByRole('form', { name: `${operation} arguments`, exact: true });
|
||||
if (await form.count() === 0) await page.getByRole('button', { name: `Run ${operation}`, exact: true }).click();
|
||||
await expect(form).toBeVisible();
|
||||
await expect(form).toHaveAttribute('data-operation-form', operation);
|
||||
return form;
|
||||
}
|
||||
async function execute(page: Page, iface: string, operation: string, args?: Record<string, { kind: string; value: string }>) {
|
||||
const toggle = page.getByRole('button', { name: new RegExp(`^[?>v] /::${iface}$`) });
|
||||
if (await toggle.getAttribute('aria-expanded') !== 'true') await toggle.click();
|
||||
await page.getByLabel('Operation', { exact: true }).selectOption(operation);
|
||||
if (args) await page.getByLabel('Arguments (tagged JSON record)').fill(JSON.stringify(args));
|
||||
const calls = Number(await panel(page).getAttribute('data-calls'));
|
||||
await page.getByRole('button', { name: 'Execute Operation', exact: true }).click();
|
||||
const form = await openOperation(page, operation);
|
||||
await expect(panel(page)).toHaveAttribute('data-calls', String(calls));
|
||||
for (const [parameter, argument] of Object.entries(args ?? {})) {
|
||||
await form.getByLabel(`${operation}.${parameter}`, { exact: true }).fill(argument.value);
|
||||
}
|
||||
await form.getByRole('button', { name: `Execute ${operation}`, exact: true }).click();
|
||||
await expect(page.getByLabel('Operation result')).toBeVisible();
|
||||
await expect(panel(page)).toHaveAttribute('data-calls', String(calls + 1));
|
||||
await expect(page.locator('[data-protocol-status]')).toBeHidden();
|
||||
@@ -29,40 +38,45 @@ async function execute(page: Page, iface: string, operation: string, args?: obje
|
||||
const nameArg = (name: string) => ({ name: { kind: 'string', value: name } });
|
||||
const amountArg = (amount: string) => ({ amount: { kind: 'integer', value: amount } });
|
||||
|
||||
test('collection and counter mutations update the tree through actual Observe', async ({ page }, info) => {
|
||||
test('Observe updates tree membership while counter values come only from Operations', async ({ page }, info) => {
|
||||
await page.route('**/wip-wasm/wip_browser.js?instance=protocol', async route => {
|
||||
const response = await route.fetch();
|
||||
await route.fulfill({ response, body: `${await response.text()}\nconst observe = BrowserSession.prototype.observe; BrowserSession.prototype.observe = function(path, depth) { (window.__observations ||= []).push({path,depth}); return observe.call(this,path,depth); };` });
|
||||
});
|
||||
await page.goto(url(info.project.use.baseURL!)); await ready(page);
|
||||
await expect(value(page, 'apple')).toHaveText('3');
|
||||
await expect(value(page, 'orange')).toHaveText('0');
|
||||
await expect(node(page, 'apple')).toHaveText('apple');
|
||||
await expect(node(page, 'orange')).toHaveText('orange');
|
||||
await expect(page.locator('.node-value')).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: '? /::collection', exact: true })).toBeVisible();
|
||||
await expect(panel(page)).toHaveAttribute('data-calls', '0');
|
||||
await execute(page, 'collection', 'create', nameArg('banana'));
|
||||
await expect(value(page, 'banana')).toHaveText('0');
|
||||
await expect(node(page, 'banana')).toHaveText('banana');
|
||||
await node(page, 'banana').click(); await ready(page, '/playground/banana');
|
||||
await expect(node(page, 'banana')).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(page.getByRole('button', { name: '? /::counter', exact: true })).toBeVisible();
|
||||
expect((await execute(page, 'counter', 'get')).result).toEqual({ kind: 'integer', value: '0' });
|
||||
const metadata = await page.getByLabel('Object Text View').textContent();
|
||||
const before = Number(await panel(page).getAttribute('data-observes'));
|
||||
const increment = await execute(page, 'counter', 'increment', amountArg('2'));
|
||||
expect(increment.result).toEqual({ kind: 'integer', value: '2' });
|
||||
await expect(value(page, 'banana')).toHaveText('2');
|
||||
await expect(node(page, 'banana').locator('.node-label')).toHaveText('banana');
|
||||
await expect(page.getByLabel('Object Text View')).toContainText('description: "2"');
|
||||
await expect(node(page, 'banana')).toHaveText('banana');
|
||||
await expect(node(page, 'banana')).toHaveAttribute('aria-label', 'banana');
|
||||
expect(await page.getByLabel('Object Text View').textContent()).toBe(metadata);
|
||||
await expect(page.getByLabel('Object Text View')).toContainText('description: "An in-memory counter."');
|
||||
expect(Number(await panel(page).getAttribute('data-observes'))).toBeGreaterThan(before);
|
||||
expect(await page.evaluate(() => (window as Window & { __observations?: unknown[] }).__observations?.slice(-2)))
|
||||
.toEqual([{ path: '/playground', depth: 1 }, { path: '/playground/banana', depth: 1 }]);
|
||||
await execute(page, 'counter', 'increment', amountArg('-1'));
|
||||
expect((await execute(page, 'counter', 'get')).result).toEqual({ kind: 'integer', value: '1' });
|
||||
await execute(page, 'counter', 'reset');
|
||||
await expect(value(page, 'banana')).toHaveText('0');
|
||||
expect((await execute(page, 'counter', 'reset')).result).toEqual({ kind: 'integer', value: '0' });
|
||||
expect((await execute(page, 'counter', 'get')).result).toEqual({ kind: 'integer', value: '0' });
|
||||
await expect(node(page, 'banana')).toHaveText('banana');
|
||||
await page.screenshot({ path: info.outputPath('playground-counter.png'), fullPage: true });
|
||||
await node(page, '').click(); await ready(page);
|
||||
await execute(page, 'collection', 'remove', nameArg('banana'));
|
||||
await expect(node(page, 'banana')).toHaveCount(0);
|
||||
await execute(page, 'collection', 'create', nameArg('banana'));
|
||||
await expect(value(page, 'banana')).toHaveText('0');
|
||||
await expect(node(page, 'banana')).toHaveText('banana');
|
||||
await node(page, 'banana').click(); await ready(page, '/playground/banana');
|
||||
expect((await execute(page, 'counter', 'get')).result).toEqual({ kind: 'integer', value: '0' });
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true);
|
||||
@@ -71,27 +85,30 @@ test('collection and counter mutations update the tree through actual Observe',
|
||||
test('state survives selection, history and Read mode, but reload resets the session', async ({ page }, info) => {
|
||||
const base = info.project.use.baseURL!;
|
||||
await page.goto(url(base, 'apple')); await ready(page, '/playground/apple');
|
||||
await execute(page, 'counter', 'increment', amountArg('4'));
|
||||
await expect(value(page, 'apple')).toHaveText('7');
|
||||
expect((await execute(page, 'counter', 'increment', amountArg('4'))).result).toEqual({ kind: 'integer', value: '7' });
|
||||
await node(page, 'orange').click(); await ready(page, '/playground/orange');
|
||||
await page.goBack(); await ready(page, '/playground/apple');
|
||||
await expect(value(page, 'apple')).toHaveText('7');
|
||||
await expect(panel(page)).toHaveAttribute('data-calls', '1');
|
||||
await expect(page.getByLabel('Operation result')).toBeHidden();
|
||||
expect((await execute(page, 'counter', 'get')).result).toEqual({ kind: 'integer', value: '7' });
|
||||
await page.goForward(); await ready(page, '/playground/orange');
|
||||
await page.getByRole('button', { name: 'Read', exact: true }).click();
|
||||
await expect(page.locator('[data-selected-content] h1')).toHaveText('Playground');
|
||||
await expect(page.locator('[data-landing-stage]')).toHaveAttribute('data-wip-state', 'ready');
|
||||
await expect(page.locator('[data-playground-row]')).toHaveCount(0);
|
||||
await page.getByRole('button', { name: 'Protocol', exact: true }).click(); await ready(page, '/playground/orange');
|
||||
await expect(value(page, 'apple')).toHaveText('7');
|
||||
await expect(node(page, 'apple')).toHaveText('apple');
|
||||
await expect(panel(page)).toHaveAttribute('data-calls', '2');
|
||||
await page.locator('.demo-node[data-path="/book"]').click();
|
||||
await ready(page, '/book');
|
||||
await expect(value(page, 'apple')).toHaveText('7');
|
||||
await expect(node(page, 'apple')).toHaveText('apple');
|
||||
await node(page, 'apple').click(); await ready(page, '/playground/apple');
|
||||
await expect(value(page, 'apple')).toHaveText('7');
|
||||
await expect(panel(page)).toHaveAttribute('data-calls', '1');
|
||||
await expect(panel(page)).toHaveAttribute('data-calls', '2');
|
||||
expect((await execute(page, 'counter', 'get')).result).toEqual({ kind: 'integer', value: '7' });
|
||||
await expect(page.locator('.node-value')).toHaveCount(0);
|
||||
await page.reload(); await ready(page, '/playground/apple');
|
||||
await expect(value(page, 'apple')).toHaveText('3');
|
||||
await expect(panel(page)).toHaveAttribute('data-calls', '0');
|
||||
expect((await execute(page, 'counter', 'get')).result).toEqual({ kind: 'integer', value: '3' });
|
||||
});
|
||||
|
||||
test('invalid operations do not alter the tree and deleted history does not resurrect a counter', async ({ page }, info) => {
|
||||
@@ -108,25 +125,47 @@ test('invalid operations do not alter the tree and deleted history does not resu
|
||||
await node(page, '').click(); await ready(page);
|
||||
await page.getByRole('button', { name: '> /::collection', exact: true }).click();
|
||||
for (const invalid of ['apple', '../book', '<img src=x onerror=alert(1)>']) {
|
||||
await page.getByLabel('Operation', { exact: true }).selectOption('create');
|
||||
await page.getByLabel('Arguments (tagged JSON record)').fill(JSON.stringify(nameArg(invalid)));
|
||||
await page.getByRole('button', { name: 'Execute Operation', exact: true }).click();
|
||||
const form = await openOperation(page, 'create');
|
||||
await form.getByLabel('create.name', { exact: true }).fill(invalid);
|
||||
await form.getByRole('button', { name: 'Execute create', exact: true }).click();
|
||||
await expect(page.locator('[data-protocol-status]')).toBeVisible();
|
||||
await expect(page.getByLabel('Operation result')).toBeHidden();
|
||||
await expect(page.locator('[data-playground-row]')).toHaveCount(2);
|
||||
await expect(value(page, 'apple')).toHaveText('3');
|
||||
await expect(node(page, 'apple')).toHaveText('apple');
|
||||
}
|
||||
await expect(panel(page).locator('img')).toHaveCount(0);
|
||||
await node(page, 'apple').click(); await ready(page, '/playground/apple');
|
||||
await page.getByRole('button', { name: '? /::counter', exact: true }).click();
|
||||
await page.getByLabel('Operation', { exact: true }).selectOption('increment');
|
||||
await page.getByLabel('Arguments (tagged JSON record)').fill(JSON.stringify(amountArg('9223372036854775807')));
|
||||
await page.getByRole('button', { name: 'Execute Operation', exact: true }).click();
|
||||
const incrementForm = await openOperation(page, 'increment');
|
||||
await incrementForm.getByLabel('increment.amount', { exact: true }).fill('9223372036854775807');
|
||||
await incrementForm.getByRole('button', { name: 'Execute increment', exact: true }).click();
|
||||
await expect(page.locator('[data-protocol-status]')).toBeVisible();
|
||||
await expect(value(page, 'apple')).toHaveText('3');
|
||||
await expect(node(page, 'apple')).toHaveText('apple');
|
||||
expect((await execute(page, 'counter', 'get')).result).toEqual({ kind: 'integer', value: '3' });
|
||||
});
|
||||
|
||||
for (const variant of ['missing', 'null', 'prose', 'numeric'] as const) {
|
||||
test(`tree membership does not depend on ${variant} descriptions or fetch counter values`, async ({ page }, info) => {
|
||||
await page.route('**/wip-wasm/wip_browser.js?instance=protocol', async route => {
|
||||
const response = await route.fetch();
|
||||
await route.fulfill({ response, body: `${await response.text()}\nconst original = BrowserSession.prototype.observe; BrowserSession.prototype.observe = function(...args) { const value = JSON.parse(original.apply(this,args)); for (const child of value.observation.children || []) { const variant = ${JSON.stringify(variant)}; if (variant === 'missing') delete child.object.description; else child.object.description = variant === 'null' ? null : variant === 'numeric' ? '999999' : 'カウンターの説明 <img src=x onerror="window.__descriptionExecuted=1">'; } return JSON.stringify(value); };` });
|
||||
});
|
||||
await page.goto(url(info.project.use.baseURL!)); await ready(page);
|
||||
for (const name of ['apple', 'orange']) {
|
||||
await expect(node(page, name)).toHaveText(name);
|
||||
await expect(node(page, name)).toHaveAttribute('aria-label', name);
|
||||
}
|
||||
await expect(page.locator('.node-value, [data-playground-row] img')).toHaveCount(0);
|
||||
await expect(panel(page)).toHaveAttribute('data-calls', '0');
|
||||
await expect(panel(page)).toHaveAttribute('data-interfaces', '0');
|
||||
await node(page, 'apple').click(); await ready(page, '/playground/apple');
|
||||
await expect(panel(page)).toHaveAttribute('data-calls', '0');
|
||||
await expect(page.getByLabel('Operation result')).toBeHidden();
|
||||
expect((await execute(page, 'counter', 'get')).result).toEqual({ kind: 'integer', value: '3' });
|
||||
expect(await page.evaluate(() => '__descriptionExecuted' in window)).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
test('dynamic children participate in tree keyboard navigation and folding', async ({ page }, info) => {
|
||||
await page.goto(url(info.project.use.baseURL!)); await ready(page);
|
||||
await page.locator('.demo-node[data-path="/book"]').click(); await ready(page, '/book');
|
||||
@@ -155,30 +194,33 @@ for (const failurePath of ['/playground', '/playground/apple']) test(`failed ref
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
await page.route('**/wip-wasm/wip_browser.js?instance=protocol', async route => {
|
||||
const response = await route.fetch();
|
||||
await route.fulfill({ response, body: `${await response.text()}\nconst call = BrowserSession.prototype.call_operation; const observe = BrowserSession.prototype.observe; let failRefresh = false; BrowserSession.prototype.call_operation = function(...args) { const result = call.apply(this,args); failRefresh = true; return result; }; BrowserSession.prototype.observe = function(...args) { if(failRefresh && args[0] === ${JSON.stringify(failurePath)}) { failRefresh = false; throw new Error('refresh unavailable'); } return observe.apply(this,args); };` });
|
||||
await route.fulfill({ response, body: `${await response.text()}\nconst call = BrowserSession.prototype.call_operation; const observe = BrowserSession.prototype.observe; let failRefresh = false; BrowserSession.prototype.call_operation = function(...args) { const result = call.apply(this,args); if (args[3] === 'increment') failRefresh = true; return result; }; BrowserSession.prototype.observe = function(...args) { if(failRefresh && args[0] === ${JSON.stringify(failurePath)}) { failRefresh = false; throw new Error('refresh unavailable'); } return observe.apply(this,args); };` });
|
||||
});
|
||||
await page.goto(url(info.project.use.baseURL!, 'apple')); await ready(page, '/playground/apple');
|
||||
await page.getByRole('button', { name: '? /::counter', exact: true }).click();
|
||||
await page.getByLabel('Operation', { exact: true }).selectOption('increment');
|
||||
expect(JSON.parse(await page.getByLabel('Arguments (tagged JSON record)').inputValue())).toEqual(amountArg('1'));
|
||||
await page.getByRole('button', { name: 'Execute Operation', exact: true }).click();
|
||||
const form = await openOperation(page, 'increment');
|
||||
await expect(form.getByLabel('increment.amount', { exact: true })).toHaveValue('1');
|
||||
await form.getByRole('button', { name: 'Execute increment', exact: true }).click();
|
||||
await expect(page.locator('[data-protocol-status]')).toContainText('Operationは成功しましたが再Observeに失敗');
|
||||
expect(JSON.parse(await page.getByLabel('Operation result').innerText()).result).toEqual({ kind: 'integer', value: '4' });
|
||||
await expect(panel(page)).toHaveAttribute('data-calls', '1');
|
||||
// Stale observations and target readiness are retired, not inferred from the Call result.
|
||||
if (failurePath === '/playground') await expect(node(page, 'apple')).toHaveCount(0);
|
||||
else await expect(value(page, 'apple')).toHaveText('4');
|
||||
await expect(page.getByRole('button', { name: 'Execute Operation', exact: true })).toBeDisabled();
|
||||
await expect(page.getByLabel('Operation', { exact: true })).toBeDisabled();
|
||||
else await expect(node(page, 'apple')).toHaveText('apple');
|
||||
await expect(form.getByRole('button', { name: 'Execute increment', exact: true })).toBeDisabled();
|
||||
await expect(form.getByLabel('increment.amount', { exact: true })).toBeDisabled();
|
||||
await expect(page.getByRole('button', { name: 'Run increment', exact: true })).toBeDisabled();
|
||||
await expect(page.getByLabel('Object Text View')).toBeHidden();
|
||||
await page.locator('.demo-node[data-path="/book"]').click(); await ready(page, '/book');
|
||||
if (failurePath === '/playground') await expect(node(page, 'apple')).toHaveCount(0);
|
||||
else await expect(value(page, 'apple')).toHaveText('4');
|
||||
else await expect(node(page, 'apple')).toHaveText('apple');
|
||||
await node(page, '').click(); await ready(page);
|
||||
await node(page, 'apple').click(); await ready(page, '/playground/apple');
|
||||
await expect(value(page, 'apple')).toHaveText('4');
|
||||
await expect(node(page, 'apple')).toHaveText('apple');
|
||||
await expect(panel(page)).toHaveAttribute('data-calls', '1');
|
||||
await expect(page.locator('[data-protocol-status]')).toBeHidden();
|
||||
expect((await execute(page, 'counter', 'get')).result).toEqual({ kind: 'integer', value: '4' });
|
||||
await expect(page.locator('.node-value')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test.describe('static explanation', () => {
|
||||
|
||||
@@ -14,7 +14,7 @@ async function descriptor(page: Page) {
|
||||
await expect(page.getByLabel('Object Text View')).toContainText('object {');
|
||||
await page.locator('[data-interface-toggle]').first().click();
|
||||
await expect(page.getByLabel('Interface Text View')).toContainText('interface');
|
||||
await page.getByLabel('Operation', { exact: true }).selectOption('read');
|
||||
await page.getByRole('button', { name: 'Run read', exact: true }).click();
|
||||
}
|
||||
|
||||
async function traceObserves(page: Page) {
|
||||
@@ -60,6 +60,39 @@ for (const [route, path, name] of [
|
||||
});
|
||||
}
|
||||
|
||||
for (const colorScheme of ['light', 'dark'] as const) {
|
||||
test(`Interface buttons use a text-width TUI background in ${colorScheme} mode`, async ({ page }, info) => {
|
||||
await page.emulateMedia({ colorScheme });
|
||||
await page.goto(protocolURL(info.project.use.baseURL!, 'playground/')); await ready(page);
|
||||
const button = page.getByRole('button', { name: '? /::collection', exact: true });
|
||||
const toggle = page.locator('[data-interface-toggle]').filter({ hasText: '/::collection' });
|
||||
await expect(button).toBeVisible();
|
||||
const background = await button.evaluate(element => {
|
||||
const probe = document.createElement('span'); probe.style.backgroundColor = 'var(--code-bg)';
|
||||
element.append(probe); const color = getComputedStyle(probe).backgroundColor; probe.remove(); return color;
|
||||
});
|
||||
expect(background).not.toBe('rgba(0, 0, 0, 0)');
|
||||
for (const state of ['?', 'v', '>']) {
|
||||
await page.mouse.move(0, 0);
|
||||
await page.getByRole('button', { name: 'Read', exact: true }).focus();
|
||||
await expect(toggle).toHaveText(`${state} /::collection`);
|
||||
await expect(toggle).toHaveCSS('background-color', background);
|
||||
await expect(toggle).toHaveCSS('border-radius', '0px');
|
||||
await expect(toggle).toHaveCSS('border-top-width', '0px');
|
||||
await expect(toggle).toHaveCSS('padding-top', '0px');
|
||||
expect(await toggle.evaluate(element => element.getBoundingClientRect().width < element.parentElement!.getBoundingClientRect().width)).toBe(true);
|
||||
await toggle.hover();
|
||||
await expect(toggle).not.toHaveCSS('background-color', background);
|
||||
if (state !== '>') await toggle.click();
|
||||
}
|
||||
await page.mouse.move(0, 0); await toggle.focus(); await page.keyboard.press('Enter');
|
||||
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||||
await expect(toggle).toHaveCSS('outline-width', '2px');
|
||||
await expect(page.getByLabel('Interface Text View').filter({ visible: true })).toContainText('/::collection');
|
||||
await page.screenshot({ path: info.outputPath(`interface-background-${colorScheme}.png`), fullPage: true });
|
||||
});
|
||||
}
|
||||
|
||||
test('Object selection shows an unboxed Text View without Observe forms or step panels', async ({ page }, info) => {
|
||||
await page.goto(protocolURL(info.project.use.baseURL!, 'sources/')); await ready(page);
|
||||
const object = page.getByLabel('Object Text View');
|
||||
@@ -70,16 +103,20 @@ test('Object selection shows an unboxed Text View without Observe forms or step
|
||||
await expect(object).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
|
||||
await expect(panel(page).locator('fieldset, legend, h2, h3, [data-protocol-path], [data-protocol-depth], [data-protocol-observe], [data-protocol-stats], [data-protocol-children]')).toHaveCount(0);
|
||||
await expect(page.locator('[data-protocol-status]')).toBeHidden();
|
||||
await expect(page.locator('[data-protocol-operation-controls]')).toBeHidden();
|
||||
await expect(page.locator('[data-protocol-operation-controls], [data-protocol-operation], [data-protocol-arguments], [data-operation-form], [data-protocol-call]')).toHaveCount(0);
|
||||
await expect(page.getByLabel('Operation result')).toBeHidden();
|
||||
await expect(page.getByRole('button', { name: '? /::document', exact: true })).toBeVisible();
|
||||
await page.screenshot({ path: info.outputPath('protocol-object.png'), fullPage: true });
|
||||
await page.getByRole('button', { name: '? /::document', exact: true }).click();
|
||||
await expect(page.getByLabel('Interface Text View')).toBeVisible();
|
||||
await expect(page.locator('[data-protocol-operation-controls]')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Run read', exact: true })).toBeEnabled();
|
||||
await expect(page.locator('[data-protocol-operation-controls], [data-protocol-operation], [data-protocol-arguments], [data-protocol-call]')).toHaveCount(0);
|
||||
await page.getByRole('button', { name: 'Run read', exact: true }).click();
|
||||
await expect(page.getByRole('form', { name: 'read arguments', exact: true })).toHaveAttribute('data-operation-form', 'read');
|
||||
await expect(page.getByRole('button', { name: 'Execute read', exact: true })).toBeEnabled();
|
||||
await expect(page.getByLabel('Operation result')).toBeHidden();
|
||||
await page.getByRole('button', { name: 'v /::document', exact: true }).click();
|
||||
await expect(page.locator('[data-protocol-operation-controls]')).toBeHidden();
|
||||
await expect(page.locator('[data-protocol-operation-controls], [data-protocol-operation], [data-protocol-arguments], [data-operation-form], [data-protocol-call]')).toHaveCount(0);
|
||||
await expect(object).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -96,12 +133,12 @@ test('tree selection automatically observes Depth 1 only, including root, nested
|
||||
await expect(page.getByLabel('Interface Text View')).toBeHidden();
|
||||
await expect(panel(page)).toHaveAttribute('data-interfaces', '0');
|
||||
await expect(panel(page)).toHaveAttribute('data-calls', '0');
|
||||
await expect(page.locator('[data-protocol-call]')).toBeDisabled();
|
||||
await expect(page.locator('[data-operation-form], [data-protocol-call]')).toHaveCount(0);
|
||||
expected.push({ path: canonicalPath, depth: 1 });
|
||||
expect(await observedCalls(page)).toEqual(expected);
|
||||
}
|
||||
await page.locator('[data-interface-toggle]').first().click();
|
||||
await page.getByLabel('Operation', { exact: true }).selectOption('read');
|
||||
await page.getByRole('button', { name: 'Run read', exact: true }).click();
|
||||
await page.locator('[data-protocol-call]').click();
|
||||
await expect(panel(page)).toHaveAttribute('data-calls', '1');
|
||||
// Selecting the current Object resets the previous Interface and result.
|
||||
@@ -167,12 +204,12 @@ test('Interface clicks fetch unknown signatures and fold/reopen acquired ones wi
|
||||
const signature = await view.textContent();
|
||||
await expect(panel(page)).toHaveAttribute('data-interfaces', '1');
|
||||
await expect(panel(page)).toHaveAttribute('data-calls', '0');
|
||||
await expect(page.getByLabel('Operation', { exact: true })).toBeEnabled();
|
||||
await expect(page.getByRole('button', { name: 'Run read', exact: true })).toBeEnabled();
|
||||
await toggle.focus(); await page.keyboard.press('Space');
|
||||
await expect(toggle).toHaveText('> /::document');
|
||||
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
||||
await expect(view).toBeHidden();
|
||||
await expect(page.getByLabel('Operation', { exact: true })).toBeDisabled();
|
||||
await expect(page.locator('[data-operation-form], [data-protocol-call]')).toHaveCount(0);
|
||||
await toggle.click();
|
||||
await expect(view).toHaveText(signature!);
|
||||
await expect(panel(page)).toHaveAttribute('data-interfaces', '1');
|
||||
@@ -201,21 +238,21 @@ test('failed Interface acquisition remains unknown and cannot retain a previous
|
||||
await page.goto(protocolURL(info.project.use.baseURL!)); await ready(page);
|
||||
await page.locator('.demo-node[data-path="/"]').click();
|
||||
await page.getByRole('button', { name: '? /::document', exact: true }).click();
|
||||
await page.getByLabel('Operation', { exact: true }).selectOption('read');
|
||||
await page.getByRole('button', { name: 'Run read', exact: true }).click();
|
||||
const missing = page.getByRole('button', { name: `? /::${missingName}`, exact: true });
|
||||
await missing.click();
|
||||
await expect(missing).toHaveAttribute('aria-expanded', 'false');
|
||||
await expect(page.getByRole('button', { name: '> /::document', exact: true })).toBeVisible();
|
||||
await expect(page.locator('[data-protocol-status]')).toContainText('protocol');
|
||||
await expect(page.locator('[data-interface-view]:visible')).toHaveCount(0);
|
||||
await expect(page.locator('[data-protocol-call]')).toBeDisabled();
|
||||
await expect(page.locator('[data-operation-form], [data-protocol-call]')).toHaveCount(0);
|
||||
await expect(page.getByLabel('Operation result')).toBeHidden();
|
||||
await expect(panel(page)).toHaveAttribute('data-calls', '0');
|
||||
await expect(page.locator('[data-protocol-interfaces] img')).toHaveCount(0);
|
||||
expect(await page.evaluate(() => '__interfaceExecuted' in window)).toBe(false);
|
||||
await page.getByRole('button', { name: '> /::document', exact: true }).click();
|
||||
await expect(page.locator('[data-interface-view]:visible')).toContainText('interface "/::document"');
|
||||
await expect(page.getByLabel('Operation', { exact: true })).toBeEnabled();
|
||||
await expect(page.getByRole('button', { name: 'Run read', exact: true })).toBeEnabled();
|
||||
});
|
||||
|
||||
test('Protocol observes automatically but acquires Interfaces and calls Operations only explicitly', async ({ page }, info) => {
|
||||
@@ -283,7 +320,7 @@ test('selection, history, reload and mode switches never replay an operation', a
|
||||
await ready(page);
|
||||
await expect(panel(page)).toHaveAttribute('data-calls', '1');
|
||||
await expect(page.getByLabel('Operation result')).toBeHidden();
|
||||
await expect(page.locator('[data-protocol-call]')).toBeDisabled();
|
||||
await expect(page.locator('[data-operation-form], [data-protocol-call]')).toHaveCount(0);
|
||||
}
|
||||
await page.goBack(); await ready(page);
|
||||
await expect(panel(page)).toHaveAttribute('data-path', '/sources');
|
||||
@@ -400,22 +437,25 @@ test('input and protocol errors remain visible, and tree selection clears stale
|
||||
await page.locator('.demo-node[data-path="/sources"]').click(); await ready(page);
|
||||
await expect(page.locator('[data-protocol-status]')).toBeVisible();
|
||||
await expect(page.locator('[data-protocol-status]')).toContainText('protocol');
|
||||
await expect(page.locator('[data-protocol-call]')).toBeDisabled();
|
||||
await expect(page.locator('[data-operation-form], [data-protocol-call]')).toHaveCount(0);
|
||||
await expect(page.getByLabel('Object Text View')).toBeHidden();
|
||||
await page.locator('.demo-node[data-path="/"]').click();
|
||||
await expect(panel(page)).toHaveAttribute('data-path', '/');
|
||||
await ready(page);
|
||||
await descriptor(page);
|
||||
await page.locator('[data-protocol-arguments]').fill('{broken');
|
||||
await page.locator('.demo-node[data-path="/playground"]').click(); await ready(page);
|
||||
await page.locator('.demo-node[data-path="/playground/apple"]').click(); await ready(page);
|
||||
await expect(panel(page)).toHaveAttribute('data-path', '/playground/apple');
|
||||
await page.getByRole('button', { name: '? /::counter', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Run increment', exact: true }).click();
|
||||
await page.getByLabel('increment.amount', { exact: true }).fill('broken');
|
||||
await page.locator('[data-protocol-call]').click();
|
||||
await expect(page.locator('[data-protocol-status]')).toBeVisible();
|
||||
await expect(page.locator('[data-protocol-status]')).toContainText('input');
|
||||
await expect(panel(page)).toHaveAttribute('data-calls', '0');
|
||||
await page.locator('.demo-node[data-path="/book"]').click(); await ready(page);
|
||||
await expect(page.getByLabel('Object Text View')).toContainText('name: "book"');
|
||||
await expect(page.locator('[data-protocol-operation-controls]')).toBeHidden();
|
||||
await expect(page.locator('[data-protocol-operation-controls], [data-protocol-operation], [data-protocol-arguments], [data-operation-form], [data-protocol-call]')).toHaveCount(0);
|
||||
await expect(page.locator('[data-protocol-status]')).toBeHidden();
|
||||
await expect(page.locator('[data-protocol-call]')).toBeDisabled();
|
||||
});
|
||||
|
||||
test('Text View and returned markup are displayed as text, including in dark mode', async ({ page }, info) => {
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import { expect, test, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
test.use({ javaScriptEnabled: true, reducedMotion: 'reduce' });
|
||||
interface View { textView: string; textViewTokens: { kind: string; text: string }[]; }
|
||||
type Captures = { observe?: View; fetch_interface?: View };
|
||||
async function capture(page: Page) {
|
||||
await page.route('**/wip-wasm/wip_browser.js?instance=protocol', async route => {
|
||||
const response = await route.fetch();
|
||||
await route.fulfill({ response, body: `${await response.text()}\nfor (const method of ['observe','fetch_interface']) { const original = BrowserSession.prototype[method]; BrowserSession.prototype[method] = function(...args) { const response = original.apply(this,args); (window.__textViews ||= {})[method] = JSON.parse(response); return response; }; }` });
|
||||
});
|
||||
}
|
||||
async function exactView(page: Page, view: Locator, method: keyof Captures) {
|
||||
const expected = await page.evaluate(method => (window as unknown as { __textViews: Captures }).__textViews[method]!, method);
|
||||
expect(expected.textViewTokens.length).toBeGreaterThan(0);
|
||||
expect(expected.textViewTokens.map(token => token.text).join('')).toBe(expected.textView);
|
||||
// DOM/range text preserves the final LF. Browser selection can omit an unpainted
|
||||
// trailing newline, so compare its behavior with the same unhighlighted <pre>.
|
||||
expect(await view.evaluate(element => {
|
||||
const text = element.cloneNode(true) as HTMLElement;
|
||||
text.querySelectorAll('[data-operation-control]').forEach(control => control.remove());
|
||||
return text.textContent;
|
||||
})).toBe(expected.textView);
|
||||
const copied = await view.evaluate(element => {
|
||||
const source = element.cloneNode(true) as HTMLElement; source.removeAttribute('id');
|
||||
source.querySelectorAll('[data-operation-control]').forEach(control => control.remove());
|
||||
source.querySelectorAll<HTMLElement>('*').forEach(node => { node.style.animation = 'none'; });
|
||||
source.style.position = 'absolute'; element.after(source);
|
||||
const range = document.createRange(); range.selectNodeContents(source);
|
||||
const rangeText = range.toString();
|
||||
const selection = window.getSelection()!; selection.removeAllRanges(); selection.addRange(range);
|
||||
const highlighted = selection.toString();
|
||||
const plain = source.cloneNode(false) as HTMLElement; plain.removeAttribute('id');
|
||||
plain.textContent = source.textContent; source.after(plain);
|
||||
range.selectNodeContents(plain); selection.removeAllRanges(); selection.addRange(range);
|
||||
const unhighlighted = selection.toString(); selection.removeAllRanges(); plain.remove(); source.remove();
|
||||
return { rangeText, highlighted, unhighlighted };
|
||||
});
|
||||
expect(copied.rangeText).toBe(expected.textView);
|
||||
expect(copied.highlighted).toBe(copied.unhighlighted);
|
||||
const semantic = expected.textViewTokens.filter(token => !['whitespace', 'newline', 'indentation', 'plain'].includes(token.kind));
|
||||
expect(await view.locator('[data-text-kind]').evaluateAll(elements => elements.map(element => ({ kind: (element as HTMLElement).dataset.textKind, text: element.textContent })))).toEqual(semantic);
|
||||
await expect(view.locator('img, script, a, style')).toHaveCount(0);
|
||||
}
|
||||
|
||||
for (const scheme of ['light', 'dark'] as const) {
|
||||
test(`real Text View token roles preserve content and use a readable ${scheme} palette`, async ({ page }, info) => {
|
||||
await page.emulateMedia({ colorScheme: scheme }); await capture(page);
|
||||
await page.goto(new URL('?mode=protocol', info.project.use.baseURL!).href);
|
||||
const object = page.getByLabel('Object Text View');
|
||||
await expect(object.locator('[data-text-kind="keyword"]').first()).toHaveText('object');
|
||||
await exactView(page, object, 'observe');
|
||||
await page.getByRole('button', { name: '? /::document', exact: true }).click();
|
||||
const signature = page.locator('[data-interface-view]:visible');
|
||||
await exactView(page, signature, 'fetch_interface');
|
||||
for (const kind of ['keyword', 'type-name', 'operation-name', 'field-name', 'reference', 'documentation', 'symbol']) {
|
||||
expect(await signature.locator(`[data-text-kind="${kind}"]`).count()).toBeGreaterThan(0);
|
||||
}
|
||||
const palette = await page.locator('.protocol-signature [data-text-kind]').evaluateAll(elements => {
|
||||
const probe = document.createElement('span'); probe.style.backgroundColor = 'var(--paper)'; document.body.append(probe);
|
||||
const background = getComputedStyle(probe).backgroundColor; probe.remove();
|
||||
return { background, colors: elements.map(element => getComputedStyle(element).color) };
|
||||
});
|
||||
const luminance = (color: string) => {
|
||||
const rgb = color.match(/[\d.]+/g)!.slice(0, 3).map(Number).map(value => value / 255).map(value => value <= .04045 ? value / 12.92 : ((value + .055) / 1.055) ** 2.4);
|
||||
return .2126 * rgb[0] + .7152 * rgb[1] + .0722 * rgb[2];
|
||||
};
|
||||
expect(new Set(palette.colors).size).toBeGreaterThanOrEqual(6);
|
||||
for (const color of palette.colors) {
|
||||
const a = luminance(color), b = luminance(palette.background);
|
||||
expect((Math.max(a, b) + .05) / (Math.min(a, b) + .05)).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
const text = await signature.textContent();
|
||||
const keyword = signature.locator('[data-text-kind="keyword"]').first();
|
||||
const before = await keyword.evaluate(element => getComputedStyle(element).color);
|
||||
await page.emulateMedia({ colorScheme: scheme === 'light' ? 'dark' : 'light' });
|
||||
await expect(keyword).not.toHaveCSS('color', before);
|
||||
expect(await signature.textContent()).toBe(text);
|
||||
await page.getByRole('button', { name: 'v /::document', exact: true }).click();
|
||||
await page.getByRole('button', { name: '> /::document', exact: true }).click();
|
||||
await exactView(page, signature, 'fetch_interface');
|
||||
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-interfaces', '1');
|
||||
await page.emulateMedia({ colorScheme: scheme });
|
||||
await page.screenshot({ path: info.outputPath(`text-view-${scheme}.png`), fullPage: true });
|
||||
});
|
||||
}
|
||||
|
||||
test('Unicode and markup remain literal text and Playground refresh keeps token styling', async ({ page }, info) => {
|
||||
await capture(page);
|
||||
await page.route('**/worldspace.json', async route => {
|
||||
const response = await route.fetch(); const data = await response.json();
|
||||
data.nodes[0].title = '日本語 🐈 <img src=x onerror="window.__highlightExecuted=1"> & \\ \n\t';
|
||||
await route.fulfill({ response, json: data });
|
||||
});
|
||||
await page.goto(new URL('?mode=protocol', info.project.use.baseURL!).href);
|
||||
const object = page.getByLabel('Object Text View');
|
||||
await expect(object.locator('[data-text-kind="string"]').last()).toContainText('日本語 🐈 <img');
|
||||
await exactView(page, object, 'observe');
|
||||
expect(await page.evaluate(() => '__highlightExecuted' in window)).toBe(false);
|
||||
await page.locator('.demo-node[data-path="/playground"]').click();
|
||||
await page.getByRole('button', { name: '? /::collection', exact: true }).click();
|
||||
await expect(page.locator('[data-interface-view]:visible [data-text-kind="parameter-name"]').first()).toHaveText('`name`');
|
||||
await page.locator('.demo-node[data-path="/playground/apple"]').click();
|
||||
await page.getByRole('button', { name: '? /::counter', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Run increment', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Execute increment', exact: true }).click();
|
||||
await expect(object.locator('[data-text-kind="string"]').last()).toHaveText('"An in-memory counter."');
|
||||
expect(JSON.parse(await page.getByLabel('Operation result').innerText()).result).toEqual({ kind: 'integer', value: '4' });
|
||||
await exactView(page, object, 'observe');
|
||||
await expect(page.getByLabel('Operation result').locator('[data-text-kind]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('Interface lines fade in top-to-bottom at 10ms per line, including cached reopen', async ({ page }, info) => {
|
||||
await page.emulateMedia({ reducedMotion: 'no-preference' }); await capture(page);
|
||||
await page.goto(new URL('playground/?mode=protocol', info.project.use.baseURL!).href);
|
||||
const toggle = page.getByRole('button', { name: '? /::collection', exact: true });
|
||||
await expect(toggle).toBeEnabled();
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const timing = await page.locator('[data-interface-toggle]').filter({ hasText: '/::collection' }).evaluate(button => {
|
||||
(button as HTMLButtonElement).click();
|
||||
const view = document.getElementById(button.getAttribute('aria-controls')!)!;
|
||||
const lines = [...view.querySelectorAll('.text-view-line')];
|
||||
const animations = view.getAnimations({ subtree: true });
|
||||
const timings = lines.flatMap(line => line.getAnimations()).map(animation => {
|
||||
const effect = animation.effect!;
|
||||
return { delay: Number(effect.getTiming().delay), duration: effect.getComputedTiming().duration, end: effect.getComputedTiming().endTime };
|
||||
});
|
||||
animations.forEach(animation => { animation.pause(); animation.currentTime = 20; });
|
||||
const opacity = lines.map(line => Number(getComputedStyle(line).opacity));
|
||||
const source = view.cloneNode(true) as HTMLElement;
|
||||
source.querySelectorAll('[data-operation-control]').forEach(control => control.remove());
|
||||
const text = source.textContent;
|
||||
animations.forEach(animation => animation.finish());
|
||||
return { timings, opacity, lines: lines.map(line => line.textContent), text };
|
||||
});
|
||||
expect(timing.timings.length).toBe(timing.lines.length);
|
||||
expect(timing.timings.length).toBeGreaterThan(2);
|
||||
expect(timing.timings[0].delay).toBe(0);
|
||||
expect(Math.max(...timing.timings.map(item => Number(item.end)))).toBeCloseTo(timing.lines.length * 10, 5);
|
||||
expect(timing.timings.every(item => Number(item.duration) === 10)).toBe(true);
|
||||
timing.timings.forEach((item, index) => expect(item.delay).toBe(index * 10));
|
||||
expect(timing.opacity[0]).toBeGreaterThan(0);
|
||||
expect(timing.opacity.at(-1)).toBe(0);
|
||||
expect(timing.lines.join('\n') + '\n').toBe(timing.text);
|
||||
await exactView(page, page.locator('[data-interface-view]:visible'), 'fetch_interface');
|
||||
await page.getByRole('button', { name: 'v /::collection', exact: true }).click();
|
||||
await expect(page.locator('[data-interface-view]:visible')).toHaveCount(0);
|
||||
}
|
||||
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-interfaces', '1');
|
||||
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '0');
|
||||
});
|
||||
|
||||
test('Interface height grows from zero and pushes following rows down in sync with line fades', async ({ page }, info) => {
|
||||
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
||||
await page.goto(new URL('playground/?mode=protocol', info.project.use.baseURL!).href);
|
||||
const toggle = page.locator('[data-interface-toggle]').first();
|
||||
await expect(toggle).toBeEnabled();
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const result = await toggle.evaluate(button => {
|
||||
const panel = button.closest<HTMLElement>('[data-protocol-panel]')!;
|
||||
const following = button.parentElement!.nextElementSibling!;
|
||||
const top = () => following.getBoundingClientRect().top - panel.getBoundingClientRect().top + panel.scrollTop;
|
||||
const before = top();
|
||||
(button as HTMLButtonElement).click();
|
||||
const view = document.getElementById(button.getAttribute('aria-controls')!)!;
|
||||
const animations = view.getAnimations({ subtree: true });
|
||||
const expansion = view.querySelector('.text-view-reveal')!.getAnimations()[0];
|
||||
const duration = Number(expansion.effect!.getComputedTiming().duration);
|
||||
const lineCount = view.querySelectorAll('.text-view-line').length;
|
||||
animations.forEach(animation => animation.pause());
|
||||
const frames = [0, .25, .5, .75, 1].map(progress => {
|
||||
animations.forEach(animation => { animation.currentTime = progress * duration; });
|
||||
return { height: view.getBoundingClientRect().height, following: top() };
|
||||
});
|
||||
animations.forEach(animation => animation.finish());
|
||||
return { before, frames, duration, lineCount,
|
||||
finalHeight: view.getBoundingClientRect().height,
|
||||
contentHeight: view.querySelector('.text-view-lines')!.scrollHeight };
|
||||
});
|
||||
expect(result.duration).toBe(result.lineCount * 10);
|
||||
expect(result.frames[0].height).toBe(0);
|
||||
expect(result.frames[0].following).toBeCloseTo(result.before, 1);
|
||||
for (let index = 1; index < result.frames.length; index++) {
|
||||
expect(result.frames[index].height).toBeGreaterThan(result.frames[index - 1].height);
|
||||
expect(result.frames[index].following).toBeGreaterThan(result.frames[index - 1].following);
|
||||
expect(result.frames[index].following - result.before).toBeCloseTo(result.frames[index].height, 1);
|
||||
}
|
||||
expect(result.frames.at(-1)!.height).toBeCloseTo(result.finalHeight, 1);
|
||||
expect(Math.abs(result.finalHeight - result.contentHeight)).toBeLessThan(1);
|
||||
// No retained fixed height: wrapping after expansion must still size naturally.
|
||||
const resized = await page.locator('[data-interface-view]:visible').evaluate(view => {
|
||||
(view as HTMLElement).style.width = '16ch';
|
||||
return { height: view.getBoundingClientRect().height, content: view.querySelector('.text-view-lines')!.scrollHeight };
|
||||
});
|
||||
expect(resized.height).toBeGreaterThan(result.finalHeight);
|
||||
expect(Math.abs(resized.height - resized.content)).toBeLessThan(1);
|
||||
await page.locator('[data-interface-view]:visible').evaluate(view => (view as HTMLElement).style.removeProperty('width'));
|
||||
await toggle.click();
|
||||
await expect(page.locator('[data-interface-view]:visible')).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('closing shrinks the current height and lifts following rows at 10ms per line', async ({ page }, info) => {
|
||||
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
||||
await page.goto(new URL('playground/?mode=protocol', info.project.use.baseURL!).href);
|
||||
const toggle = page.locator('[data-interface-toggle]').first();
|
||||
await expect(toggle).toBeEnabled();
|
||||
for (const openingProgress of [1, .4]) {
|
||||
const result = await toggle.evaluate((button, openingProgress) => {
|
||||
(button as HTMLButtonElement).click();
|
||||
const view = document.getElementById(button.getAttribute('aria-controls')!)!;
|
||||
const lineCount = view.querySelectorAll('.text-view-line').length;
|
||||
view.getAnimations({ subtree: true }).forEach(animation => { animation.pause(); animation.currentTime = openingProgress * lineCount * 10; });
|
||||
const panel = button.closest<HTMLElement>('[data-protocol-panel]')!;
|
||||
const following = button.parentElement!.nextElementSibling!;
|
||||
const top = () => following.getBoundingClientRect().top - panel.getBoundingClientRect().top + panel.scrollTop;
|
||||
const before = { height: view.getBoundingClientRect().height, top: top(), opacity: getComputedStyle(view.querySelector('.text-view-line')!).opacity };
|
||||
(button as HTMLButtonElement).click();
|
||||
const animations = view.getAnimations({ subtree: true });
|
||||
const duration = Number(view.querySelector('.text-view-reveal')!.getAnimations()[0].effect!.getComputedTiming().duration);
|
||||
const lineTimings = [...view.querySelectorAll('.text-view-line')].map(line => line.getAnimations()[0].effect!.getTiming());
|
||||
animations.forEach(animation => animation.pause());
|
||||
const frames = [0, .25, .5, .75, 1].map(progress => {
|
||||
animations.forEach(animation => { animation.currentTime = progress * duration; });
|
||||
return { height: view.getBoundingClientRect().height, top: top(), opacity: Number(getComputedStyle(view.querySelector('.text-view-line')!).opacity) };
|
||||
});
|
||||
const inert = view.inert;
|
||||
animations.forEach(animation => animation.finish());
|
||||
return { before, frames, inert, expanded: button.getAttribute('aria-expanded'), duration, lineCount, lineTimings };
|
||||
}, openingProgress);
|
||||
result.lineTimings.forEach((timing, index) => {
|
||||
expect(timing.duration).toBe(10);
|
||||
expect(timing.delay).toBe((result.lineCount - index - 1) * 10);
|
||||
});
|
||||
expect(result.duration).toBe(result.lineCount * 10);
|
||||
expect(result.inert).toBe(true);
|
||||
expect(result.expanded).toBe('false');
|
||||
expect(result.frames[0].height).toBeCloseTo(result.before.height, 1);
|
||||
expect(result.frames[0].top).toBeCloseTo(result.before.top, 1);
|
||||
expect(result.frames[0].opacity).toBeCloseTo(Number(result.before.opacity), 4);
|
||||
for (let index = 1; index < result.frames.length; index++) {
|
||||
expect(result.frames[index].height).toBeLessThan(result.frames[index - 1].height);
|
||||
expect(result.frames[index].top).toBeLessThan(result.frames[index - 1].top);
|
||||
expect(result.frames[index].opacity).toBeLessThanOrEqual(result.frames[index - 1].opacity);
|
||||
expect(result.before.top - result.frames[index].top).toBeCloseTo(result.before.height - result.frames[index].height, 1);
|
||||
}
|
||||
expect(result.frames.at(-1)!.height).toBe(0);
|
||||
expect(result.frames.at(-1)!.opacity).toBe(0);
|
||||
await expect(page.locator('[data-interface-view]:not([hidden])')).toHaveCount(0);
|
||||
await expect(page.locator('.text-view-reveal')).toHaveCount(0);
|
||||
await expect(page.locator('[data-protocol-call]')).toHaveCount(0);
|
||||
}
|
||||
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-interfaces', '1');
|
||||
await expect(page.locator('[data-protocol-panel]')).toHaveAttribute('data-calls', '0');
|
||||
});
|
||||
|
||||
test('reopening during collapse cannot be cleared by the old completion', async ({ page }, info) => {
|
||||
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
||||
await page.goto(new URL('playground/?mode=protocol', info.project.use.baseURL!).href);
|
||||
const toggle = page.locator('[data-interface-toggle]').first();
|
||||
await expect(toggle).toBeEnabled();
|
||||
const state = await toggle.evaluate(async button => {
|
||||
const click = () => (button as HTMLButtonElement).click();
|
||||
click();
|
||||
const view = document.getElementById(button.getAttribute('aria-controls')!)!;
|
||||
view.getAnimations({ subtree: true }).forEach(animation => animation.finish());
|
||||
click();
|
||||
const closing = view.getAnimations({ subtree: true });
|
||||
closing.forEach(animation => { animation.pause(); animation.currentTime = 50; });
|
||||
const settled = Promise.allSettled(closing.map(animation => animation.finished));
|
||||
click();
|
||||
view.getAnimations({ subtree: true }).forEach(animation => animation.finish());
|
||||
await settled;
|
||||
return { hidden: view.hidden, inert: view.inert, text: view.textContent, expanded: button.getAttribute('aria-expanded') };
|
||||
});
|
||||
expect(state.hidden).toBe(false);
|
||||
expect(state.inert).toBe(false);
|
||||
expect(state.text).toContain('interface');
|
||||
expect(state.expanded).toBe('true');
|
||||
await expect(page.locator('.text-view-closing')).toHaveCount(0);
|
||||
await toggle.click();
|
||||
await expect(page.locator('[data-interface-view]:not([hidden])')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('reduced motion and print settle a running collapse without leaving content', async ({ page }, info) => {
|
||||
await page.goto(new URL('?mode=protocol', info.project.use.baseURL!).href);
|
||||
const toggle = page.locator('[data-interface-toggle]').first();
|
||||
await expect(toggle).toBeEnabled();
|
||||
for (const preference of ['reduce', 'print']) {
|
||||
await page.emulateMedia({ media: 'screen', reducedMotion: 'no-preference' });
|
||||
await toggle.evaluate(button => {
|
||||
(button as HTMLButtonElement).click();
|
||||
const view = document.getElementById(button.getAttribute('aria-controls')!)!;
|
||||
view.getAnimations({ subtree: true }).forEach(animation => animation.finish());
|
||||
(button as HTMLButtonElement).click();
|
||||
view.getAnimations({ subtree: true }).forEach(animation => { animation.pause(); animation.currentTime = 50; });
|
||||
});
|
||||
await expect(page.locator('.text-view-closing')).toHaveCount(1);
|
||||
await page.emulateMedia(preference === 'reduce' ? { reducedMotion: 'reduce' } : { media: 'print' });
|
||||
await expect(page.locator('.text-view-reveal')).toHaveCount(0);
|
||||
await expect(page.locator('[data-interface-view]:not([hidden])')).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('rapid collapse and switching Interfaces cannot restore old animated text', async ({ page }, info) => {
|
||||
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
||||
await page.goto(new URL('playground/?mode=protocol', info.project.use.baseURL!).href);
|
||||
await expect(page.getByRole('button', { name: '? /::collection', exact: true })).toBeEnabled();
|
||||
await page.evaluate(() => {
|
||||
const buttons = [...document.querySelectorAll<HTMLButtonElement>('[data-interface-toggle]')];
|
||||
const collection = buttons.find(button => button.textContent?.includes('/::collection'))!;
|
||||
const documentButton = buttons.find(button => button.textContent?.includes('/::document'))!;
|
||||
collection.click();
|
||||
document.getElementById(collection.getAttribute('aria-controls')!)!.getAnimations({ subtree: true }).forEach(animation => animation.pause());
|
||||
collection.click(); documentButton.click();
|
||||
});
|
||||
const view = page.locator('[data-interface-view]:visible');
|
||||
await expect(view).toContainText('interface "/::document"');
|
||||
await expect.poll(() => view.evaluate(element => element.getAnimations({ subtree: true }).length)).toBe(0);
|
||||
await expect(page.getByRole('button', { name: '> /::collection', exact: true })).toHaveAttribute('aria-expanded', 'false');
|
||||
expect(await page.locator('[data-interface-view][hidden]').textContent()).toBe('');
|
||||
await page.evaluate(() => {
|
||||
document.querySelector<HTMLButtonElement>('[data-interface-toggle][aria-expanded="true"]')!.click();
|
||||
document.querySelector<HTMLButtonElement>('[data-mode="read"]')!.click();
|
||||
});
|
||||
await expect(page.locator('.text-view-line')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('reduced motion disables expansion and fades, including a live preference change', async ({ page }, info) => {
|
||||
await page.goto(new URL('?mode=protocol', info.project.use.baseURL!).href);
|
||||
await page.getByRole('button', { name: '? /::document', exact: true }).click();
|
||||
const view = page.locator('[data-interface-view]:visible');
|
||||
expect(await view.evaluate(element => element.getAnimations({ subtree: true }).length)).toBe(0);
|
||||
await expect(view.locator('.text-view-line').first()).toHaveCSS('opacity', '1');
|
||||
const fullHeight = await view.evaluate(element => element.getBoundingClientRect().height);
|
||||
expect(fullHeight).toBeGreaterThan(0);
|
||||
await page.getByRole('button', { name: 'v /::document', exact: true }).click();
|
||||
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
||||
const count = await page.getByRole('button', { name: '> /::document', exact: true }).evaluate(button => {
|
||||
(button as HTMLButtonElement).click();
|
||||
const animations = document.getElementById(button.getAttribute('aria-controls')!)!.getAnimations({ subtree: true });
|
||||
animations.forEach(animation => { animation.pause(); animation.currentTime = 0; });
|
||||
return animations.length;
|
||||
});
|
||||
expect(count).toBeGreaterThan(0);
|
||||
expect(await page.locator('[data-interface-view]:not([hidden])').evaluate(element => element.getBoundingClientRect().height)).toBe(0);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await expect.poll(() => view.evaluate(element => element.getAnimations({ subtree: true }).length)).toBe(0);
|
||||
await expect(view.locator('.text-view-line').last()).toHaveCSS('opacity', '1');
|
||||
expect(await view.evaluate(element => element.getBoundingClientRect().height)).toBeCloseTo(fullHeight, 1);
|
||||
await page.emulateMedia({ media: 'print', reducedMotion: 'no-preference' });
|
||||
expect(await view.evaluate(element => element.getAnimations({ subtree: true }).length)).toBe(0);
|
||||
});
|
||||
|
||||
for (const variant of ['missing', 'mismatch', 'unknown-kind'] as const) {
|
||||
test(`token compatibility fallback is safe for ${variant}`, async ({ page }, info) => {
|
||||
await page.route('**/wip-wasm/wip_browser.js?instance=protocol', async route => {
|
||||
const response = await route.fetch();
|
||||
await route.fulfill({ response, body: `${await response.text()}\nconst original = BrowserSession.prototype.observe; BrowserSession.prototype.observe = function(...args) { const value = JSON.parse(original.apply(this,args)); const variant = ${JSON.stringify(variant)}; if (variant === 'missing') delete value.textViewTokens; else value.textViewTokens = [{kind:'unknown" onclick="alert(1)',text:variant === 'mismatch' ? '<img src=x onerror=alert(1)>' : value.textView}]; window.__plainSignature = value.textView; return JSON.stringify(value); };` });
|
||||
});
|
||||
await page.goto(new URL('?mode=protocol', info.project.use.baseURL!).href);
|
||||
const object = page.getByLabel('Object Text View'); await expect(object).toBeVisible();
|
||||
expect(await object.textContent()).toBe(await page.evaluate(() => (window as unknown as { __plainSignature: string }).__plainSignature));
|
||||
await expect(object.locator('*')).toHaveCount(0);
|
||||
await page.getByRole('button', { name: '? /::document', exact: true }).click();
|
||||
await expect(page.locator('[data-interface-view]:visible [data-text-kind="keyword"]').first()).toHaveText('interface');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user