Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions concordia/utils/visual_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@

from concordia.typing import prefab as prefab_lib


# Color schemes for different roles
_COLORS = {
prefab_lib.Role.ENTITY: {
Expand Down Expand Up @@ -442,7 +441,8 @@ class names in the Inspector.
Complete HTML page as a string.
"""
svg, entity_data = visualize_config(config, checkpoint_data)
entity_data_json = json.dumps(entity_data)
# Keep literal closing script tags in component state inside the JSON data.
entity_data_json = json.dumps(entity_data).replace("<", "\\u003c")

html = f"""<!DOCTYPE html>
<html lang="en">
Expand Down Expand Up @@ -706,6 +706,9 @@ class names in the Inspector.
width: 100%;
box-sizing: border-box;
margin-top: 2px;
min-width: 0;
resize: vertical;
line-height: 1.4;
}}

.dynamic-input:focus {{
Expand Down Expand Up @@ -1056,6 +1059,7 @@ class names in the Inspector.

// Build component sections
let html = '';
const dynamicValues = [];

// Component Info section (if available from checkpoint)
if (data.component_info) {{
Expand Down Expand Up @@ -1105,7 +1109,8 @@ class names in the Inspector.
html += '</div>';
html += '<div class="dynamic-row-editor">';
const valStr = typeof stateVal === 'object' ? JSON.stringify(stateVal) : String(stateVal);
html += `<input class="dynamic-input" id="${{inputId}}" type="text" value="${{escapeHtml(valStr)}}" />`;
html += `<textarea class="dynamic-input" id="${{inputId}}" rows="3"></textarea>`;
dynamicValues.push({{id: inputId, value: valStr}});
html += `<button class="dynamic-save-btn" data-entity="${{escapeHtml(data.name)}}" data-component="${{escapeHtml(key)}}" data-state-key="${{escapeHtml(stateKey)}}" data-input-id="${{inputId}}">Save</button>`;
html += '</div>';
html += '</div>';
Expand Down Expand Up @@ -1158,6 +1163,12 @@ class names in the Inspector.

content.innerHTML = html;

// Assign prose as a DOM value: quotes and markup stay literal, and
// textarea controls preserve multiline text through Save and SSE refresh.
dynamicValues.forEach(({{id, value}}) => {{
document.getElementById(id).value = value;
}});

content.querySelectorAll('.dynamic-save-btn').forEach(btn => {{
btn.addEventListener('click', function() {{
saveComponentState(
Expand Down
203 changes: 203 additions & 0 deletions concordia/utils/visual_interface_browser_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
# Copyright 2026 DeepMind Technologies Limited.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Optional Chromium round-trip checks using the standard editor and server.

Install Playwright and its Chromium browser, then run:
python -m pytest -n 0 concordia/utils/visual_interface_browser_test.py

Only component editing is exercised: no simulation steps or model calls run.
The module is skipped when the optional Playwright package is absent.
"""

import copy
import json
from unittest import mock

from concordia.environment.engines import sequential
from concordia.language_model import no_language_model
from concordia.prefabs.entity import minimal
from concordia.prefabs.simulation import generic
from concordia.typing import entity_component
from concordia.typing import prefab as prefab_lib
from concordia.utils import simulation_server
from concordia.utils import visual_interface
import numpy as np
import pytest

browser_api = pytest.importorskip('playwright.sync_api')

_VALUES = (
'Explain "ren" and \'li\'.',
'\nFirst line\n\nSecond line\n',
'<b>literal</b> & &amp;',
'仁 λ 🛰️ e\u0301',
'',
' \t leading and trailing \t ',
'</textarea><span id="text-markup-probe">literal</span>',
'</script><script>globalThis.textProbe = true;</script>',
)


@pytest.fixture(scope='module')
def browser():
with browser_api.sync_playwright() as playwright:
chromium = playwright.chromium.launch()
yield chromium
chromium.close()


def _states(simulation):
return {
entity.name: entity.get_state() for entity in simulation.get_entities()
}


@pytest.mark.parametrize('index', range(len(_VALUES)))
def test_literal_prose_roundtrip(browser, index, tmp_path):
initial = _VALUES[index]
edited = _VALUES[(index + 1) % len(_VALUES)]
# Players are already in visual order: this does not depend on mixed-role
# card identity behavior, which is a separate concern.
config = prefab_lib.Config(
prefabs={'minimal': minimal.Entity()},
instances=[
prefab_lib.InstanceConfig(
prefab='minimal',
role=prefab_lib.Role.ENTITY,
params={'name': name, 'custom_instructions': value},
)
for name, value in [('Alice', initial), ('Bob', 'Do not edit Bob.')]
],
)
with (
mock.patch.object(generic.Simulation, 'play', side_effect=AssertionError),
mock.patch.object(
sequential.Sequential, 'run_loop', side_effect=AssertionError
),
mock.patch.object(
no_language_model.NoLanguageModel,
'sample_text',
side_effect=AssertionError,
),
mock.patch.object(
no_language_model.NoLanguageModel,
'sample_choice',
side_effect=AssertionError,
),
):
simulation = generic.Simulation(
config=config,
model=no_language_model.NoLanguageModel(),
embedder=lambda _: np.ones(3),
engine=sequential.Sequential(),
)
before = _states(simulation)
checkpoint = simulation.make_checkpoint_data()
server = simulation_server.SimulationServer(
port=0,
html_content=visual_interface.visualize_config_to_html(
config, checkpoint_data=checkpoint
),
)
server.set_simulation(simulation)
server.broadcast_entity_info(checkpoint)
server.start()
page = browser.new_page(viewport={'width': 1440, 'height': 1000})
errors = []
page.on('pageerror', lambda error: errors.append(str(error)))
try:
page.goto(f'http://127.0.0.1:{server.bound_port}/')
browser_api.expect(page.locator('#console-output')).to_contain_text(
'Connected to simulation server'
)
page.locator('[data-entity-id="entity_0"]').click()
browser_api.expect(page.locator('#inspector-title')).to_have_text('Alice')
control = page.locator('#dyn_Instructions_state')
browser_api.expect(control).to_have_value(initial)
page.locator('#toggle_comp_Instructions').click()
control.fill(edited)
button = page.locator('[data-input-id="dyn_Instructions_state"]')
with page.expect_response('**/cmd/set_component_state') as response:
button.click()
assert response.value.json()['status'] == 'ok'
# Wait for the real SSE checkpoint, not just the locally edited control.
page.wait_for_function(
"""value => entityData.entity_0.component_info.context_components
.Instructions.state.state === value""",
arg=edited,
)
browser_api.expect(control).to_have_value(edited)
expected = copy.deepcopy(before)
expected['Alice']['context_components']['Instructions']['state'] = edited
assert _states(simulation) == expected
entity = simulation.get_entities()[0]
assert isinstance(entity, entity_component.EntityWithComponents)
component = entity.get_component('Instructions')
assert component.get_state()['state'] == edited
assert component.get_state()['pre_act_label'] == '\nInstructions'
# Read-only metadata has no edit control; backend validation still rejects
# attempts to change it, even when bypassing the UI.
assert page.locator('[data-state-key="pre_act_label"]').count() == 0
with pytest.raises(ValueError):
simulation.set_component_dynamic_state(
'Alice', 'Instructions', 'pre_act_label', 'not permitted'
)
# The HTTP document still has the initial checkpoint. The existing SSE
# reconnect must replace it with the saved value on reload.
page.reload()
page.wait_for_function(
"""value => entityData.entity_0.component_info.context_components
.Instructions.state.state === value""",
arg=edited,
)
page.locator('[data-entity-id="entity_0"]').click()
browser_api.expect(control).to_have_value(edited)
page.locator('#toggle_comp_Instructions').click()
page.screenshot(path=str(tmp_path / 'saved-and-reloaded.png'))
# A no-op Save after repaint/reload must not silently alter prose.
with page.expect_response('**/cmd/set_component_state') as response:
button.click()
assert response.value.json()['status'] == 'ok'
assert component.get_state()['state'] == edited
page.wait_for_function(
"""value => entityData.entity_0.component_info.context_components
.Instructions.state.state === value""",
arg=edited,
)
(tmp_path / 'roundtrip.json').write_text(
json.dumps(
{
'initial': initial,
'saved': edited,
'reloaded': control.input_value(),
'component_state': component.get_state(),
'other_entity_unchanged': (
_states(simulation)['Bob'] == before['Bob']
),
'simulation_steps': 0,
'model_calls': 0,
},
ensure_ascii=False,
indent=2,
),
encoding='utf-8',
)
assert page.locator('#text-markup-probe').count() == 0
assert not page.evaluate('Boolean(globalThis.textProbe)')
assert not errors
assert not server.current_step_data
finally:
page.close()
server.stop()
110 changes: 110 additions & 0 deletions concordia/utils/visual_interface_text_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
# Copyright 2026 DeepMind Technologies Limited.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Tests for literal component data in the editor's initial HTML document."""

from html import parser
import json

from absl.testing import absltest
from absl.testing import parameterized
from concordia.typing import prefab as prefab_lib
from concordia.utils import visual_interface


class _Scripts(parser.HTMLParser):
"""Extracts script text with the browser's raw-text parsing boundaries."""

def __init__(self):
super().__init__()
self.scripts = []
self.in_script = False

def handle_starttag(self, tag, attrs):
if tag == 'script':
self.scripts.append('')
self.in_script = True

def handle_endtag(self, tag):
if tag == 'script':
self.in_script = False

def handle_data(self, data):
if self.in_script:
self.scripts[-1] += data


class LiteralComponentDataTest(parameterized.TestCase):

@parameterized.parameters(
'Explain "ren" and \'li\'.',
'\nFirst line\n\nSecond line\n',
'<b>literal</b> & &amp;',
'仁 λ 🛰️ e\u0301',
'',
'</textarea><span id="text-markup-probe">literal</span>',
'</script><script>globalThis.textProbe = true;</script>',
'<!-- </ScRiPt> literal closing tag',
)
def test_initial_script_preserves_component_state(self, value):
config = prefab_lib.Config(
prefabs={},
instances=[
prefab_lib.InstanceConfig(
prefab='minimal',
role=prefab_lib.Role.ENTITY,
params={'name': 'Alice'},
)
],
)
checkpoint = {
'entities': {
'Alice': {
'component_info': {
'context_components': {
'Instructions': {
'class_name': 'Constant',
'state': {
'state': value,
'pre_act_label': '\nInstructions',
},
'dynamic_state': {'state': value},
}
},
}
}
},
'game_masters': {},
}
html = visual_interface.visualize_config_to_html(
config, checkpoint_data=checkpoint
)
document = _Scripts()
document.feed(html)
self.assertLen(document.scripts, 1)
script = document.scripts[0]
data_start = script.index('const entityData = ') + len(
'const entityData = '
)
data, _ = json.JSONDecoder().raw_decode(script[data_start:])
component = data['entity_0']['component_info']['context_components'][
'Instructions'
]
self.assertEqual(component['state']['state'], value)
self.assertEqual(component['dynamic_state'], {'state': value})
self.assertEqual(component['state']['pre_act_label'], '\nInstructions')


if __name__ == '__main__':
absltest.main()
Loading