From b4f266dae1f28982f6c43df4d1b9ffa8f3e54d50 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 9 Sep 2026 13:30:07 -0400 Subject: [PATCH 1/5] feat(mobile): read exact selected mention capability evidence Signed-off-by: Logan Johnson --- .../mentions/agent_identity_provider.dart | 1 + mobile/lib/shared/mentions/agent_policy.dart | 4 + .../selected_mention_authorization.dart | 173 +++++++++++++ .../selected_mention_authorization_test.dart | 238 ++++++++++++++++++ 4 files changed, 416 insertions(+) create mode 100644 mobile/lib/shared/mentions/selected_mention_authorization.dart create mode 100644 mobile/test/shared/mentions/selected_mention_authorization_test.dart diff --git a/mobile/lib/shared/mentions/agent_identity_provider.dart b/mobile/lib/shared/mentions/agent_identity_provider.dart index 00e54e21850..a036b255245 100644 --- a/mobile/lib/shared/mentions/agent_identity_provider.dart +++ b/mobile/lib/shared/mentions/agent_identity_provider.dart @@ -11,6 +11,7 @@ import '../../shared/relay/relay.dart'; part 'agent_policy.dart'; part 'agent_authorization.dart'; part 'agent_publication.dart'; +part 'selected_mention_authorization.dart'; /// A relay agent parsed from its kind:10100 agent-profile event. /// diff --git a/mobile/lib/shared/mentions/agent_policy.dart b/mobile/lib/shared/mentions/agent_policy.dart index 9d2d57a2742..0abc2e076bb 100644 --- a/mobile/lib/shared/mentions/agent_policy.dart +++ b/mobile/lib/shared/mentions/agent_policy.dart @@ -24,11 +24,14 @@ Future> _queryAgentFilters( /// Overlay current owner-authenticated policy onto the existing runtime /// directory. This does not expand discovery to owner-only coordinates yet. +/// [onProfileEvidence] observes immutable latest query heads after the final +/// scope check. It does not resolve ownership or mutate any shared profile cache. Future> resolveAgentPolicies( RelaySessionNotifier session, List runtimeEvents, { Set? requestedKeys, void Function()? checkCurrent, + void Function(Map profiles)? onProfileEvidence, }) async { final latest = {}; for (final event in runtimeEvents.where((event) => event.kind == 10100)) { @@ -64,6 +67,7 @@ Future> resolveAgentPolicies( ), ], checkCurrent: checkCurrent); checkCurrent?.call(); + onProfileEvidence?.call(Map.unmodifiable(profiles)); return mergeAgentPolicies(latest.values, policies, owners); } diff --git a/mobile/lib/shared/mentions/selected_mention_authorization.dart b/mobile/lib/shared/mentions/selected_mention_authorization.dart new file mode 100644 index 00000000000..a88966fadde --- /dev/null +++ b/mobile/lib/shared/mentions/selected_mention_authorization.dart @@ -0,0 +1,173 @@ +part of 'agent_identity_provider.dart'; + +/// Evidence requirements, not a claim that an ordinary identity is human. +enum SelectedMentionKind { ordinary, agent, unresolvedAgent } + +/// Fresh evidence for one exact selected key. This is NOT permission to write: +/// consumers must apply their existing agent eligibility evaluator to [agent], +/// normal channel invitation permission, consent and scope fences separately. +/// An existing member needs no role write, whatever their current role is. +class SelectedMentionAuthorization { + final SelectedMentionKind kind; + final bool isMember; + + /// Policy projection with relay-authenticated destination membership only. + /// Null for ordinary identities or unresolved prior-agent evidence. Missing + /// owner policy produces a deny-all entry, never runtime fallback. + final AgentDirectoryEntry? agent; + + const SelectedMentionAuthorization._(this.kind, this.isMember, this.agent); + + bool get requiresAgentAuthorization => kind != SelectedMentionKind.ordinary; + + /// Role selection only, never an invitation authorization. A consumer must + /// first validate eligibility and consent and skip writes for existing members. + String? get invitationRole => switch (kind) { + SelectedMentionKind.ordinary => 'member', + SelectedMentionKind.agent => 'bot', + SelectedMentionKind.unresolvedAgent => null, + }; +} + +/// Read all selected keys, not just candidates whose saved isAgent bit is true. +/// [priorAgentKeys] is denial-only taint: lost provenance cannot turn a saved +/// agent capability into an ordinary-member invitation or notification bypass. +/// Absence of agent evidence permits the existing ordinary membership flow, +/// NOT a humanity assertion. Positive fresh evidence overrides saved false. +/// +/// Every requested key has a result or the entire read throws. This read makes +/// no cache writes and does not opt into resolveProfileOwners. Check [isCurrent] +/// again before each side effect; re-read after consent and accepted invitations. +Future> +readSelectedMentionAuthorization( + RelaySessionNotifier session, + Set requestedKeys, { + required String viewer, + required String channelId, + Set priorAgentKeys = const {}, + required bool Function() isCurrent, +}) async { + void check() { + if (!isCurrent()) throw StateError('Mention authorization scope changed'); + } + + check(); + if (requestedKeys.length > 1000 || + !requestedKeys.containsAll(priorAgentKeys) || + requestedKeys.any((key) => !RegExp(r'^[0-9a-f]{64}$').hasMatch(key)) || + !RegExp(r'^[0-9a-f]{64}$').hasMatch(viewer) || + channelId.isEmpty) { + throw StateError('Invalid selected mention request'); + } + if (requestedKeys.isEmpty) return const {}; + final authority = await session.fetchRelaySelf(); + check(); + final membership = await _membershipPages( + session, + authority, + viewer, + channelId, + check, + ); + check(); + NostrEvent? roster; + for (final event in membership) { + if (event.kind != 39002 || + event.pubkey != authority || + event.getTagValue('d') != channelId) { + continue; + } + if (roster == null || _newer(event, roster)) roster = event; + } + // A malformed newest head is unavailable, never permission to invite someone + // whose current membership could not be established. Missing head is likewise + // not a trustworthy empty roster at this destination. + if (roster == null || + !verifySignedEvent(roster) || + roster.tags.where((t) => t.isNotEmpty && t[0] == 'd').length != 1) { + throw StateError('Destination membership unavailable'); + } + final members = {}; + for (final tag in roster.tags.where((t) => t.isNotEmpty && t[0] == 'p')) { + if (tag.length < 2 || members.containsKey(tag[1])) { + throw StateError('Ambiguous destination membership'); + } + members[tag[1]] = tag.length >= 4 ? tag[3] : 'member'; + } + final runtime = await _queryAgentFilters(session, [ + for (final key in requestedKeys) + NostrFilter(kinds: const [10100], authors: [key], limit: 1), + ], checkCurrent: check); + check(); + Map profiles = const {}; + final policies = await resolveAgentPolicies( + session, + runtime.where((e) => requestedKeys.contains(e.pubkey)).toList(), + requestedKeys: requestedKeys, + checkCurrent: check, + onProfileEvidence: (value) => profiles = value, + ); + check(); + final latestRuntime = {}; + for (final event in runtime) { + if (event.kind != 10100 || !requestedKeys.contains(event.pubkey)) continue; + final previous = latestRuntime[event.pubkey]; + if (previous == null || _newer(event, previous)) { + latestRuntime[event.pubkey] = event; + } + } + final byKey = {for (final policy in policies) policy.pubkey: policy}; + final result = {}; + for (final key in requestedKeys) { + final profile = profiles[key]; + final owner = profile == null ? null : verifiedOaOwnerPubkey(profile); + final invalidProfile = + profile != null && + (!verifySignedEvent(profile) || + (owner == null && + profile.tags.any((t) => t.isNotEmpty && t[0] == 'auth'))); + final runtimeHead = latestRuntime[key]; + final knownAgent = + owner != null || + members[key] == 'bot' || + (runtimeHead != null && verifySignedEvent(runtimeHead)); + final unresolved = + invalidProfile || + (runtimeHead != null && !verifySignedEvent(runtimeHead)) || + (!knownAgent && priorAgentKeys.contains(key)); + final kind = unresolved + ? SelectedMentionKind.unresolvedAgent + : knownAgent + ? SelectedMentionKind.agent + : SelectedMentionKind.ordinary; + AgentDirectoryEntry? agent; + if (kind == SelectedMentionKind.agent) { + final policy = byKey[key]; + // Ownership without a matching valid policy must not revive headless + // runtime authority. mergeAgentPolicies remains the sole policy parser. + final missingOwnedPolicy = owner != null && policy?.ownerPubkey != owner; + agent = AgentDirectoryEntry( + pubkey: key, + ownerPubkey: owner, + displayName: policy?.displayName, + respondTo: missingOwnedPolicy ? 'nobody' : policy?.respondTo, + respondToAllowlist: missingOwnedPolicy + ? const [] + : policy?.respondToAllowlist ?? const [], + channelIds: + members.containsKey(viewer) && + members.containsKey(key) && + (owner == viewer || members[key] == 'bot') + ? [channelId] + : const [], + ); + } + result[key] = SelectedMentionAuthorization._( + kind, + members.containsKey(key), + agent, + ); + } + check(); + return Map.unmodifiable(result); +} diff --git a/mobile/test/shared/mentions/selected_mention_authorization_test.dart b/mobile/test/shared/mentions/selected_mention_authorization_test.dart new file mode 100644 index 00000000000..e826d2df59d --- /dev/null +++ b/mobile/test/shared/mentions/selected_mention_authorization_test.dart @@ -0,0 +1,238 @@ +import 'package:buzz/shared/mentions/agent_identity_provider.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:nostr/nostr.dart' as nostr; + +import '../crypto/nip_oa_test.dart' show authTag, profile; +import 'agent_policy_test.dart' show PolicySession, signed; + +class _Session extends PolicySession { + _Session(super.events, this.authority, {super.failPolicy}); + final String authority; + void Function(List)? onQuery; + @override + Future fetchRelaySelf() async => authority; + @override + Future> queryRelay( + List filters, { + Duration timeout = const Duration(seconds: 8), + }) async { + onQuery?.call(filters); + return super.queryRelay(filters, timeout: timeout); + } +} + +void main() { + final viewer = nostr.Keys.generate(); + final person = nostr.Keys.generate(); + final agent = nostr.Keys.generate(); + final relay = nostr.Keys.generate(); + final owned = profile(agent, [authTag(viewer, agent.public)]); + final runtime = signed(agent, 10100, {'respond_to': 'anyone'}); + NostrEvent roster({String? role, bool personMember = true, int time = 100}) => + signed( + relay, + 39002, + '', + time: time, + tags: [ + ['d', 'room'], + ['p', viewer.public], + if (personMember) ['p', person.public, '', 'member'], + if (role != null) ['p', agent.public, '', role], + ], + ); + NostrEvent policy(String mode, {int time = 100}) => signed( + viewer, + 30177, + {'name': 'Agent', 'parallelism': 1, 'respond_to': mode}, + time: time, + tags: [ + ['d', agent.public], + ], + ); + + Future> read( + List events, { + Set prior = const {}, + bool Function()? current, + _Session? session, + Set? keys, + }) async { + final source = session ?? _Session(events, relay.public); + final container = ProviderContainer( + overrides: [relaySessionProvider.overrideWith(() => source)], + ); + container.read(relaySessionProvider); + try { + return await readSelectedMentionAuthorization( + source, + keys ?? {person.public, agent.public}, + viewer: viewer.public, + channelId: 'room', + priorAgentKeys: prior, + isCurrent: current ?? () => true, + ); + } finally { + container.dispose(); + } + } + + test( + 'exact total map preserves ordinary member and nonmember flows', + () async { + for (final member in [true, false]) { + final result = await read([roster(personMember: member)]); + expect(result.keys.toSet(), {person.public, agent.public}); + final ordinary = result[person.public]!; + expect(ordinary.kind, SelectedMentionKind.ordinary); + expect(ordinary.requiresAgentAuthorization, isFalse); + expect(ordinary.isMember, member); + expect(ordinary.invitationRole, 'member'); + expect(() => result.clear(), throwsUnsupportedError); + } + }, + ); + + test( + 'saved false cannot exclude fresh agent or select member role', + () async { + final result = await read([ + roster(role: 'member'), + owned, + policy('anyone'), + ]); + final fresh = result[agent.public]!; + expect(fresh.kind, SelectedMentionKind.agent); + expect(fresh.requiresAgentAuthorization, isTrue); + expect(fresh.invitationRole, 'bot'); + expect(fresh.isMember, isTrue); + expect(fresh.agent!.ownerPubkey, viewer.public); + expect(fresh.agent!.channelIds, ['room']); + expect(result[person.public]!.kind, SelectedMentionKind.ordinary); + }, + ); + + test( + 'saved true with lost provenance remains unresolved even as member', + () async { + final result = await read( + [roster(role: 'member'), owned, profile(agent, [], createdAt: 101)], + prior: {agent.public}, + ); + final lost = result[agent.public]!; + expect(lost.kind, SelectedMentionKind.unresolvedAgent); + expect(lost.isMember, isTrue); + expect(lost.requiresAgentAuthorization, isTrue); + expect(lost.agent, isNull); + expect(lost.invitationRole, isNull); + }, + ); + + test( + 'missing and revoked owner policy deny without runtime fallback', + () async { + for (final policies in >[ + [], + [policy('anyone'), policy('nobody', time: 101)], + ]) { + final result = await read([ + roster(role: 'bot'), + owned, + runtime, + ...policies, + ]); + final fresh = result[agent.public]!; + expect(fresh.kind, SelectedMentionKind.agent); + expect(fresh.agent!.respondTo, 'nobody'); + expect(fresh.agent!.ownerPubkey, viewer.public); + } + }, + ); + + test( + 'known runtime agent without membership never becomes ordinary', + () async { + final result = await read([roster(), runtime]); + expect(result[agent.public]!.kind, SelectedMentionKind.agent); + expect(result[agent.public]!.agent!.channelIds, isEmpty); + expect(result[agent.public]!.isMember, isFalse); + }, + ); + + test( + 'headless bot requires runtime policy; malformed OA is unresolved', + () async { + final bot = (await read([roster(role: 'bot')]))[agent.public]!; + expect(bot.kind, SelectedMentionKind.agent); + expect(bot.agent!.respondTo, isNull); + final good = (await read([roster(role: 'bot'), runtime]))[agent.public]!; + expect(good.agent!.respondTo, 'anyone'); + expect(good.agent!.channelIds, ['room']); + final bad = (await read([ + roster(role: 'bot'), + runtime, + profile(agent, [ + ['auth', viewer.public, '', 'bad'], + ]), + ]))[agent.public]!; + expect(bad.kind, SelectedMentionKind.unresolvedAgent); + expect(bad.agent, isNull); + }, + ); + + test( + 'newest roster removal wins; invalid head does not revive old member', + () async { + final result = await read([ + roster(role: 'bot'), + roster(time: 101), + runtime, + ]); + expect(result[agent.public]!.isMember, isFalse); + expect(result[agent.public]!.agent!.channelIds, isEmpty); + final invalid = NostrEvent.fromJson({ + ...roster(time: 102).toJson(), + 'content': 'tampered', + }); + await expectLater(read([roster(), invalid]), throwsStateError); + await expectLater(read([]), throwsStateError); + }, + ); + + test( + 'policy failures and scope retirement throw rather than partial map', + () async { + await expectLater( + read( + [], + session: _Session([roster(), owned], relay.public, failPolicy: true), + ), + throwsStateError, + ); + var current = true; + final session = _Session([roster(), owned], relay.public) + ..onQuery = (filters) { + if (filters.any((f) => f.kinds.contains(0))) current = false; + }; + await expectLater( + read([], session: session, current: () => current), + throwsStateError, + ); + expect(session.queries.any((f) => f.kinds.contains(30177)), isFalse); + }, + ); + + test('exact request bounds and denial-only taint are validated', () async { + await expectLater(read([roster()], keys: {'not-a-key'}), throwsStateError); + await expectLater( + read([roster()], keys: {person.public}, prior: {agent.public}), + throwsStateError, + ); + final keys = { + for (var i = 0; i < 1001; i++) i.toRadixString(16).padLeft(64, '0'), + }; + await expectLater(read([roster()], keys: keys), throwsStateError); + }); +} From 17c3b83c60e106902565d3abd879b7456ade9fa9 Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Wed, 9 Sep 2026 22:50:43 -0400 Subject: [PATCH 2/5] test(desktop): open buffered videos through latest-message action Signed-off-by: Logan Johnson --- desktop/tests/e2e/video-attachment.spec.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/desktop/tests/e2e/video-attachment.spec.ts b/desktop/tests/e2e/video-attachment.spec.ts index 2c86f5a9bda..4960e7c0a6f 100644 --- a/desktop/tests/e2e/video-attachment.spec.ts +++ b/desktop/tests/e2e/video-attachment.spec.ts @@ -1614,6 +1614,17 @@ test("playback speed persists across videos and reloads", async ({ page }) => { const player = page .locator(`[data-message-id="${emitted.id}"]`) .getByTestId("video-player"); + // Interacting with the previous player can leave the timeline reading an + // older row. Open buffered arrivals through the normal latest-message UI; + // subscription readiness alone does not make their rows visible. + await expect + .poll(async () => { + if (await player.isVisible()) return true; + const scrollToLatest = page.getByTestId("message-scroll-to-latest"); + if (await scrollToLatest.isVisible()) await scrollToLatest.click(); + return false; + }) + .toBe(true); await expect(player).toBeVisible(); await player.getByRole("button", { name: "Play video" }).click(); return player; From 1fe1193a29744cf7bd26e2a138db047169ecad7c Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 10 Sep 2026 00:05:47 -0400 Subject: [PATCH 3/5] docs(mobile): document selected mention authorization public API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answer review 5162334206: document every new public member of SelectedMentionAuthorization — the SelectedMentionKind values (ordinary, agent, unresolvedAgent), the kind and isMember fields, and the requiresAgentAuthorization getter. The docs state that ordinary is the absence of agent evidence, not a humanity claim; that isMember is destination-roster presence at read time only; and that the getter routes evidence evaluation rather than granting a write. Docs comments only; no executable or test change. Signed-off-by: Logan Johnson --- .../selected_mention_authorization.dart | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/mobile/lib/shared/mentions/selected_mention_authorization.dart b/mobile/lib/shared/mentions/selected_mention_authorization.dart index a88966fadde..c7ef3710887 100644 --- a/mobile/lib/shared/mentions/selected_mention_authorization.dart +++ b/mobile/lib/shared/mentions/selected_mention_authorization.dart @@ -1,14 +1,34 @@ part of 'agent_identity_provider.dart'; /// Evidence requirements, not a claim that an ordinary identity is human. -enum SelectedMentionKind { ordinary, agent, unresolvedAgent } +enum SelectedMentionKind { + /// No fresh agent evidence for this key. Absence of agent evidence, not + /// a humanity claim: the ordinary membership flow applies. + ordinary, + + /// Fresh verified agent evidence for this key: a verified NIP-OA profile + /// owner, a `bot` destination-roster role, or a signature-verified latest + /// kind:10100 runtime event. Evidence only, never permission to write. + agent, + + /// Agent evidence that is not safely established: an invalid profile + /// attestation, an unverifiable latest runtime head, or prior-agent + /// provenance whose fresh evidence is gone. Never demoted to the ordinary + /// flow; carries no agent entry or invitation role. + unresolvedAgent, +} /// Fresh evidence for one exact selected key. This is NOT permission to write: /// consumers must apply their existing agent eligibility evaluator to [agent], /// normal channel invitation permission, consent and scope fences separately. /// An existing member needs no role write, whatever their current role is. class SelectedMentionAuthorization { + /// Fresh evidence classification for this exact key. Values state what + /// fresh evidence showed, never permission, role, or humanity. final SelectedMentionKind kind; + + /// Presence in the destination's signed roster at read time only, not a + /// role, eligibility, or authorization claim. final bool isMember; /// Policy projection with relay-authenticated destination membership only. @@ -18,6 +38,10 @@ class SelectedMentionAuthorization { const SelectedMentionAuthorization._(this.kind, this.isMember, this.agent); + /// True when this key's evidence must be routed through the agent + /// authorization evaluator rather than the ordinary flow: agent or + /// unresolvedAgent evidence. Routing only, never a granted write; + /// eligibility, consent, and invitation fences still apply separately. bool get requiresAgentAuthorization => kind != SelectedMentionKind.ordinary; /// Role selection only, never an invitation authorization. A consumer must From 612a131b24a3a57e2b365de87dc98007e165a17d Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 10 Sep 2026 01:29:49 -0400 Subject: [PATCH 4/5] test(desktop): bind workflow message fills to step controls The outgoing trigger inspector also labels its value input "Message text", so the shared label could fill the trigger field instead of the new send_message step during the AnimatePresence wait-mode swap, silently writing a trigger filter that suppresses the run-often warning and times out the Turn-on wait. Intersect the label with the first-step #wf-step-0-text control at all three workflows.spec.ts seams, assert the filled value, and reuse the already validated local-controls helper binding and settled operator geometry. Signed-off-by: Logan Johnson --- .../tests/e2e/workflow-local-controls.spec.ts | 9 +++++++-- desktop/tests/e2e/workflows.spec.ts | 18 +++++++++++++++--- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/desktop/tests/e2e/workflow-local-controls.spec.ts b/desktop/tests/e2e/workflow-local-controls.spec.ts index 6a8319082c8..97f70051c99 100644 --- a/desktop/tests/e2e/workflow-local-controls.spec.ts +++ b/desktop/tests/e2e/workflow-local-controls.spec.ts @@ -63,7 +63,12 @@ async function addMessageStep( ) { await dialog.getByRole("button", { name: "Add step", exact: true }).click(); await page.getByRole("menuitem", { name: "Send Message" }).click(); - await dialog.getByLabel("Message text").fill("Workflow notification"); + // The outgoing trigger inspector also labels its input "Message text". + const stepText = dialog + .getByLabel("Message text", { exact: true }) + .and(dialog.locator("#wf-step-0-text")); + await stepText.fill("Workflow notification"); + await expect(stepText).toHaveValue("Workflow notification"); } async function createEnabled( @@ -288,6 +293,7 @@ test("round-trips and reopens structured message-text conditions", async ({ await openTriggerInspector(dialog); const matchControls = dialog.getByRole("group", { name: "Match" }); const operatorButtons = matchControls.getByRole("button"); + await waitForAnimations(page); const firstOperatorBox = await operatorButtons.nth(0).boundingBox(); const secondOperatorBox = await operatorButtons.nth(1).boundingBox(); const thirdOperatorBox = await operatorButtons.nth(2).boundingBox(); @@ -299,7 +305,6 @@ test("round-trips and reopens structured message-text conditions", async ({ Math.abs((secondOperatorBox?.y ?? 0) - (firstOperatorBox?.y ?? 0)), ).toBeLessThan(1); expect(thirdOperatorBox?.y).toBeGreaterThan(firstOperatorBox?.y ?? 0); - await waitForAnimations(page); await matchControls.screenshot({ path: "test-results/workflow-message-condition-operators.png", }); diff --git a/desktop/tests/e2e/workflows.spec.ts b/desktop/tests/e2e/workflows.spec.ts index 16844a2f3e2..38e95034f00 100644 --- a/desktop/tests/e2e/workflows.spec.ts +++ b/desktop/tests/e2e/workflows.spec.ts @@ -96,7 +96,11 @@ async function createWorkflow( await dialog.getByRole("button", { name: "Add step", exact: true }).click(); await page.getByRole("menuitem", { name: "Send Message" }).click(); - await dialog.getByLabel("Message text").fill("Workflow notification"); + const messageText = dialog + .getByLabel("Message text") + .and(dialog.locator("#wf-step-0-text")); + await messageText.fill("Workflow notification"); + await expect(messageText).toHaveValue("Workflow notification"); if (options?.stepName) { await dialog.getByRole("button", { name: "Step details" }).click(); await dialog.getByLabel("Name (optional)").fill(options.stepName); @@ -700,7 +704,11 @@ test("captures the built editor at desktop and narrow widths", async ({ await editWorkflowName(dialog, "editor_screenshot"); await dialog.getByRole("button", { name: "Add step", exact: true }).click(); await page.getByRole("menuitem", { name: "Send Message" }).click(); - await dialog.getByLabel("Message text").fill("Notify the workflow channel"); + const messageText = dialog + .getByLabel("Message text") + .and(dialog.locator("#wf-step-0-text")); + await messageText.fill("Notify the workflow channel"); + await expect(messageText).toHaveValue("Notify the workflow channel"); const inspector = dialog.getByTestId("workflow-node-inspector"); for (const viewport of [ @@ -805,7 +813,11 @@ test("pane routes use stable IDs and Form/YAML changes stay synchronized", async await dialog.getByRole("button", { name: "Add step", exact: true }).click(); await page.getByRole("menuitem", { name: "Send Message" }).click(); - await dialog.getByLabel("Message text").fill("first message"); + const messageText = dialog + .getByLabel("Message text") + .and(dialog.locator("#wf-step-0-text")); + await messageText.fill("first message"); + await expect(messageText).toHaveValue("first message"); await expect(page).toHaveURL(/pane=step%3Astep_1/); await dialog.getByRole("button", { name: "Add after Step 1" }).click(); From 893c668e1475997142c27dc661def869f56a848a Mon Sep 17 00:00:00 2001 From: Logan Johnson Date: Thu, 10 Sep 2026 02:16:02 -0400 Subject: [PATCH 5/5] test(desktop): pin workflow form-builder fill handoff and saved payload The committed form-builder scenario could not distinguish a wrong-target "Message text" fill under ordinary timing: the outgoing trigger inspector keeps its identically labeled input actionable through the AnimatePresence wait-mode handoff to the first step textarea, so the repaired locator still needs committed causal coverage. Slow only the animations that contain the outgoing trigger input, run the unchanged helper fill and value assertion inside that real handoff window, then parse the actual recorded create_workflow.payload.yamlDefinition to require the persisted step_1 id and "Workflow notification" text and an unfiltered message_posted trigger. Under this schedule a first-seam wrong-target mutant fails at the existing value assertion instead of passing 1/1; the restored scenario and the full 29-test workflows spec stay green. The check reads the production serializer's own saved YAML (steps[0].id/text), not a test-authored shape. Signed-off-by: Logan Johnson --- desktop/tests/e2e/workflows.spec.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/desktop/tests/e2e/workflows.spec.ts b/desktop/tests/e2e/workflows.spec.ts index 38e95034f00..af83102b88a 100644 --- a/desktop/tests/e2e/workflows.spec.ts +++ b/desktop/tests/e2e/workflows.spec.ts @@ -221,8 +221,32 @@ test("creates a workflow via the form builder", async ({ page }) => { const workflowName = `test_workflow_${Date.now()}`; await navigateToWorkflows(page); + // Keep the outgoing trigger's Message text mounted during the step handoff. + await page.evaluate(() => { + const animate = Element.prototype.animate; + Element.prototype.animate = function (...args) { + const animation = animate.apply(this, args); + if (this.querySelector("#wf-trigger-trigger_text-value")) { + animation.updatePlaybackRate(0.1); + } + return animation; + }; + }); await createWorkflow(page, workflowName); + const yaml = await page.evaluate(() => { + const call = [...(window.__BUZZ_E2E_COMMAND_PAYLOADS__ ?? [])] + .reverse() + .find((candidate) => candidate.command === "create_workflow"); + return (call?.payload as { yamlDefinition?: string } | undefined) + ?.yamlDefinition; + }); + const saved = parseYaml(yaml ?? ""); + expect(saved.steps[0].id).toBe("step_1"); + expect(saved.steps[0].text).toBe("Workflow notification"); + expect(saved.trigger.on).toBe("message_posted"); + expect(saved.trigger.filter).toBeUndefined(); + // Verify workflow appears in the list await expect(page.getByTestId("workflows-view")).toContainText(workflowName); });