diff --git a/CrestApps.OrchardCore.slnx b/CrestApps.OrchardCore.slnx
index 82b357632..4a3dbeda2 100644
--- a/CrestApps.OrchardCore.slnx
+++ b/CrestApps.OrchardCore.slnx
@@ -49,6 +49,9 @@
+
+
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
index a51356514..e7ab1052d 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -7,6 +7,7 @@
+
diff --git a/src/CrestApps.OrchardCore.Documentations/docs/ai/index.md b/src/CrestApps.OrchardCore.Documentations/docs/ai/index.md
index 82c783721..fc351ed55 100644
--- a/src/CrestApps.OrchardCore.Documentations/docs/ai/index.md
+++ b/src/CrestApps.OrchardCore.Documentations/docs/ai/index.md
@@ -41,6 +41,7 @@ The **Orchestrator** (`IOrchestrator`) is the central runtime that manages AI co
| [AI Chat](chat) | Admin and frontend chat interfaces |
| [AI Chat Interactions](chat-interactions) | Ad-hoc chat with configurable parameters and document upload |
| [AI Prompt Templates](prompt-templates) | Centralized prompt management with Liquid templates and file-based discovery |
+| [AI Playwright Browser Automation](playwright) | Persistent browser workflows and live page observation for Orchard Core admin |
| [Consuming AI Services](consuming-ai-services) | Using AI services programmatically via code |
| [Copilot Integration](copilot) | GitHub Copilot SDK-based orchestration |
| [Data Sources](data-sources/) | Retrieval-augmented generation (RAG) / knowledge base indexing and vector search |
diff --git a/src/CrestApps.OrchardCore.Documentations/docs/ai/playwright.md b/src/CrestApps.OrchardCore.Documentations/docs/ai/playwright.md
new file mode 100644
index 000000000..d31ec8ec7
--- /dev/null
+++ b/src/CrestApps.OrchardCore.Documentations/docs/ai/playwright.md
@@ -0,0 +1,109 @@
+---
+sidebar_label: Playwright
+sidebar_position: 11
+title: AI Playwright Browser Automation
+description: Configure CrestApps.OrchardCore.AI.Playwright for multi-step browser workflows and interactive page observation in Orchard Core admin.
+---
+
+# AI Playwright Browser Automation
+
+The `CrestApps.OrchardCore.AI.Playwright` module gives an AI profile a dedicated browser that can work inside Orchard Core admin and stay available for follow-up tasks.
+
+## What It Does
+
+- Opens a dedicated Playwright browser window for the current AI profile
+- Reuses that browser across follow-up tasks instead of closing it after every answer
+- Keeps its own sign-in state and can use the saved Playwright login when the admin login page appears
+- Lets the AI inspect the live page before answering questions such as "do you see the HtmlBody widget?"
+- Lets the AI continue from the current page when you ask for another task
+- Shows a visible AI target indicator in the browser before clicks and typing so users can see what the operator is about to act on
+
+## Session Behavior
+
+When Playwright is enabled on an AI profile:
+
+- The browser stays open after a task completes
+- The assistant is expected to ask whether you have another task for the same browser session
+- If you continue, the next task reuses the same browser and current page context
+- If you are done, the browser remains available until the inactivity timeout closes it naturally
+
+The session timeout follows the AI profile session inactivity timeout when the profile provides one. Otherwise, Playwright uses a 30 minute inactivity timeout.
+
+## Interactive Page Guidance
+
+The Playwright operator supports live page observation, not just one-shot execution.
+
+Examples:
+
+- `Do you see the HtmlBody widget?`
+- `What widgets do you see on this page?`
+- `Can you edit it?`
+- `Take a screenshot of the current page.`
+
+When the user asks an observation question, the operator should inspect the live page first and answer from the current browser state instead of guessing from memory.
+
+## Built-In OrchardCore Tools
+
+The Playwright operator now exposes OrchardCore-specific tools for admin workflows and live-page follow-up work:
+
+| Tool | Purpose |
+| --- | --- |
+| `playwright_capture_state` | Capture the current URL, title, heading, toast, validation messages, and visible buttons |
+| `playwright_open_content_items` | Open the Orchard content items list |
+| `playwright_list_content_items` | List visible Orchard content items from the current content items screen |
+| `playwright_open_content_item_editor` | Open an existing Orchard content item editor by title using the current list first |
+| `playwright_open_editor_tab` | Open an Orchard editor tab, summary, or section by name |
+| `playwright_set_content_title` | Set the Orchard title field |
+| `playwright_set_field_value` | Update typed Orchard fields such as text, textarea, select, and checkbox |
+| `playwright_set_body_field` | Update HtmlBody and other body-like fields with append or replace behavior |
+| `playwright_save_draft` | Save the current Orchard content item as a draft |
+| `playwright_publish_content` | Publish the current Orchard content item and return verification evidence |
+| `playwright_get_page_content` | Return the visible text content of the current page |
+| `playwright_find_element` | Find visible elements that match a text snippet, label, or widget name |
+| `playwright_check_element_exists` | Check whether a requested widget, control, or text snippet is visible |
+| `playwright_get_visible_widgets` | List visible widget-like cards, headings, and editor sections |
+| `playwright_take_screenshot` | Save a screenshot of the current page and return the saved file path |
+
+## Content Editing Reliability
+
+The Orchard admin automation now uses content-aware editing behavior instead of generic browser actions:
+
+- When the assistant needs to edit an existing content item from the content list, it scopes the action to the row that contains the requested title before clicking `Edit`
+- If one visible content item is already the clear match, the assistant should open it directly instead of asking for extra confirmation
+- When the assistant is already on the content items screen, follow-up requests should continue from that screen instead of restarting from admin home
+- When you ask to list content items, the assistant should list the visible titles directly instead of asking whether it should list them
+- When you ask to open an existing content item, the assistant can use the current list and a best-match title search before falling back to broader navigation
+- When a field is hidden behind an Orchard editor tab or section, the assistant should open that tab or section before declaring the field missing
+- Typed fields such as text, textarea, select, and checkbox are handled through Orchard-specific field tools instead of a generic fill tool
+- When the assistant fills `HtmlBody` or other body-like fields, it checks whether the live editor is a textarea, rich text surface, contenteditable region, TinyMCE instance, or iframe editor
+- For body-like fields, the assistant supports both append and replace behavior explicitly
+- Field edits do not automatically save or publish. The assistant should pause after editing unless you explicitly ask it to save or publish.
+- Publish results are only reported as successful after the tool returns verification signals from the resulting page state
+- If Orchard returns an application error such as `database is locked`, the assistant should report that exact app failure instead of pretending the browser action succeeded
+- If a retry is needed, the assistant should summarize the final verified state instead of sending conflicting success and failure messages
+
+## Configuration
+
+In the AI Profile editor, keep the Playwright configuration simple:
+
+- Enable Playwright browser automation
+- Provide the Playwright login username and password if the browser should sign in automatically when it reaches Orchard admin login
+- Optionally enable publish-by-default if you want task completion to publish unless you explicitly ask for a draft
+
+The browser interaction style is fixed to the dedicated Playwright browser workflow. End users do not need to choose browser modes.
+
+## Multi-Step Workflow Example
+
+1. `Create a new SitePage titled "Launch Draft".`
+2. The assistant completes the task and asks whether there is another task for the same browser session.
+3. You reply: `Yes. Open the Content tab and update HtmlBody.`
+4. The assistant opens the Orchard editor tab, updates the body field, and pauses until you ask to save or publish.
+5. You reply: `Publish it.`
+6. The assistant publishes the item and reports the final verified publish state.
+
+## Notes
+
+- The operator should prefer Orchard-specific tools instead of exposing generic browser click or fill tools to the AI.
+- Observation questions should use the inspection tools before answering.
+- The browser is not closed automatically after every successful task anymore.
+- The browser window now shows a visible indicator for click and typing attempts to make the live automation easier to follow.
diff --git a/src/CrestApps.OrchardCore.Documentations/docs/changelog/v2.0.0.md b/src/CrestApps.OrchardCore.Documentations/docs/changelog/v2.0.0.md
index 0eaab2138..2a7abbaf7 100644
--- a/src/CrestApps.OrchardCore.Documentations/docs/changelog/v2.0.0.md
+++ b/src/CrestApps.OrchardCore.Documentations/docs/changelog/v2.0.0.md
@@ -191,6 +191,23 @@ A new suite of modules for multi-channel communication:
## Improvements
+### AI Playwright Browser Automation (`CrestApps.OrchardCore.AI.Playwright`)
+
+- **Persistent multi-step browser sessions** — Playwright browser windows now remain open across follow-up tasks instead of closing immediately after each completed assistant reply.
+- **Inactivity-based cleanup** — Inactive Playwright browser sessions are now closed by a background cleanup task using the configured profile session timeout when available, or a 30 minute default timeout.
+- **Interactive page observation tools** — Added deterministic live-page tools for `playwright_get_page_content`, `playwright_find_element`, `playwright_check_element_exists`, `playwright_get_visible_widgets`, and `playwright_take_screenshot`.
+- **Interactive completion flow** — The Playwright operator prompt now tells the assistant to ask whether the user has another task after each completed workflow and to answer page-observation questions from the live browser state instead of guessing.
+
+- **Row-scoped content list editing** — Content item list actions now resolve the row that contains the requested title before invoking row-level actions such as `Edit`, avoiding misclicks when multiple items are visible.
+- **Smart `HtmlBody` editor detection** — `playwright_fill_by_label` now detects visible textareas, hidden source textareas, rich text surfaces, contenteditable editors, and iframe-backed editors for fields such as `HtmlBody`.
+- **Append-first body editing** — Body-like fields now append new text by default instead of overwriting existing content unless the user explicitly asks to replace the content.
+- **Stricter completion reporting** — The Playwright operator prompt now instructs the assistant to avoid conflicting success and failure statements in the same reply and to report save or publish success only from the latest verified page observation.
+- **Visible content list skills** — Added `playwright_list_content_items` and `playwright_open_content_item_editor` so the assistant can stay on the current Orchard content list, report visible items directly, and open an editor by title without restarting from admin home.
+
+- **Visible action indicator** — The dedicated Playwright browser now shows an in-page highlight ring and label before clicks and typing so users can see what the operator is targeting.
+- **Current-page-first editing flow** — The Playwright operator now opens a clear visible content-item match directly, stays on the current page for follow-up requests, and avoids auto-saving after a field edit unless the user explicitly asks.
+- **Clearer Orchard failure reporting** — Save and publish flows now surface application error headlines such as `database is locked` from the returned Orchard page state, and Playwright tool failures are returned as structured results instead of raw tool exceptions.
+
### Configuration-Based AI Deployments
- **appsettings.json deployments** — AI deployments defined in configuration (both connection-based `Deployments` arrays and the new standalone `CrestApps_AI:Deployments` array) are now automatically available at runtime. This allows site owners to define deployments in `appsettings.json` so they are shared across all tenants without per-tenant configuration.
@@ -198,7 +215,6 @@ A new suite of modules for multi-channel communication:
- **Read-only and ephemeral** — Configuration-sourced deployments appear in dropdown menus and API queries alongside database-managed deployments, but are not persisted to the database. Removing them from configuration removes them from the system.
- **Deterministic IDs** — Configuration deployments receive stable, deterministic IDs derived from their provider, connection, and deployment names, ensuring consistent references across application restarts.
----
### MCP Client Authentication (`CrestApps.OrchardCore.AI.Mcp`)
@@ -213,8 +229,6 @@ A new suite of modules for multi-channel communication:
**Breaking:** `IMcpClientTransportProvider.Get()` has been renamed to `GetAsync()` and now returns `Task` instead of `IClientTransport`. Custom transport provider implementations must update their method signature.
----
-
### Unified Citation & Reference System
The citation and reference system has been completely reworked so that **every AI provider** (Azure OpenAI, OpenAI, Ollama, Azure AI Inference) now returns the same citation references. Previously, citations only worked with Azure OpenAI's native data-sources feature (`GetMessageContext()`); since we now inject context ourselves via preemptive RAG and tool-based search, that approach no longer applied.
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Playwright/AITemplates/Prompts/playwright-operator.md b/src/Modules/CrestApps.OrchardCore.AI.Playwright/AITemplates/Prompts/playwright-operator.md
new file mode 100644
index 000000000..e4466af91
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Playwright/AITemplates/Prompts/playwright-operator.md
@@ -0,0 +1,146 @@
+---
+Title: Playwright Operator
+Description: Deterministic OrchardCore admin browser guidance for Playwright-enabled sessions.
+Parameters:
+ - baseUrl: string
+ - adminBaseUrl: string
+ - publishBehavior: string
+IsListable: false
+Category: Orchestration
+---
+
+You are the CrestApps OrchardCore Playwright operator.
+
+Your job is to complete OrchardCore admin workflows safely, deterministically, and with OrchardCore-specific reasoning.
+Do not treat OrchardCore admin like a generic website.
+Treat it like a structured CMS editor with content lists, editor tabs, Orchard fields, widget containers, publish workflows, and verification rules.
+
+Current Playwright session:
+- Browser session: dedicated Playwright browser that keeps its own sign-in state
+- Base URL: {{ baseUrl }}
+- Admin URL: {{ adminBaseUrl }}
+- Default completion behavior: {{ publishBehavior }}
+- Session lifecycle: keep the browser open across follow-up tasks until the user is finished or the inactivity timeout closes it naturally
+
+Operating model:
+
+1. Plan the next OrchardCore step before calling a tool.
+2. First identify the current page type from live evidence whenever possible: login, dashboard, content list, content editor, content definition editor, settings page, or preview page.
+3. Prefer the highest-level OrchardCore-specific tool that matches the task.
+4. Execute one verified action at a time.
+5. After each mutating action, inspect the returned URL, title, heading, toast, validation messages, status indicators, or screenshot evidence before continuing.
+6. Continue from the current page whenever possible instead of restarting navigation.
+7. Never guess when the live page can be inspected.
+
+Tool selection order:
+
+1. Use OrchardCore navigation and content workflow tools first:
+ - `playwright_capture_state`
+ - `playwright_open_admin_home`
+ - `playwright_open_content_items`
+ - `playwright_list_content_items`
+ - `playwright_open_content_item_editor`
+ - `playwright_open_new_content_item`
+ - `playwright_open_editor_tab`
+ - `playwright_set_content_title`
+ - `playwright_set_field_value`
+ - `playwright_set_body_field`
+ - `playwright_save_draft`
+ - `playwright_publish_content`
+ - `playwright_publish_and_verify`
+2. Use Orchard-aware inspection tools next:
+ - `playwright_get_page_content`
+ - `playwright_find_element`
+ - `playwright_check_element_exists`
+ - `playwright_get_visible_widgets`
+ - `playwright_take_screenshot`
+ - `playwright_diagnose_orchard_action`
+3. Do not improvise raw browser selectors. If the current Orchard-specific tool surface cannot solve the task safely, stop and say what Orchard-specific tool is missing.
+
+Selector rules:
+
+1. Prefer dedicated OrchardCore tools over selectors.
+2. Keep selector logic inside Orchard services, not in the AI plan.
+3. Scope actions to OrchardCore containers such as the current content row, current editor tab, current fieldset, current card, or current widget container.
+4. Do not fall back to raw browser selectors from the prompt.
+
+OrchardCore workflow skills:
+
+1. Admin navigation skill
+ - Use the configured `adminBaseUrl`.
+ - Never assume the admin prefix is `/Admin`.
+ - If the current page already contains the needed context, stay on that page.
+2. Content list skill
+ - Treat the content items screen as the authoritative source for visible titles, types, status, and row actions.
+ - When the user asks to find or edit a specific content item, locate the row containing that title first.
+ - If one visible match is clearly best, use it directly and state which title was matched.
+ - If the match is weak, report the closest visible titles instead of editing the wrong row.
+3. Content editor skill
+ - Wait for the content editor to be clearly open before editing.
+ - Prefer the visible `Title` label first, then Orchard title fallbacks.
+ - Use `playwright_open_editor_tab` before declaring a tabbed or collapsed field missing.
+ - Use `playwright_set_field_value` for typed fields.
+ - Use `playwright_set_body_field` for HtmlBody, Summary, and other rich or body-like editors.
+ - Changing a field is not the same as saving.
+4. Widget and nested editor skill
+ - OrchardCore widgets may appear as cards, panels, legends, summaries, repeated items, `data-widget-type` surfaces, or heading-based editor sections.
+ - For `FlowPart`, `BagPart`, and nested widget workflows, identify the parent container first, then the target widget card, then the field inside that widget.
+ - Expand collapsed sections before claiming the widget is missing.
+ - If the current tool layer cannot identify the widget deterministically, capture state, list visible widgets, and explain the limitation instead of guessing.
+5. Publish and verification skill
+ - `Save Draft` and `Publish` are different outcomes.
+ - Do not publish unless the user explicitly asks for publish or the profile is configured to publish by default.
+ - Prefer `playwright_publish_and_verify` when the task needs proof that publish completed successfully.
+ - If evidence is weak, take a screenshot or capture state again before reporting success.
+6. Screenshot and diagnostics skill
+ - Use screenshots for debugging, visual confirmation, ambiguous layouts, and multi-step checkpoints.
+ - Use page content and element inspection for grounded answers to live UI questions.
+ - Report the verified final state only.
+
+Rules:
+
+1. Prefer Orchard-specific Playwright skills over generic browser actions.
+2. When the current page is unclear, capture the current state before making a risky move.
+3. Execute one verified step at a time.
+4. Trust the configured URLs and the current observation, not assumptions.
+5. Prefer these Orchard admin flows:
+ - ensure admin home is available
+ - open content items
+ - open an existing content item editor by title
+ - open a new content item for the requested content type
+ - open an editor tab or section before editing hidden fields
+ - set the content title
+ - set typed field values
+ - set body-like field values with append or replace semantics
+ - save draft or publish according to the user's request
+6. When the user asks to find the `Edit` button for a specific item, identify the correct row first and use that row's action.
+7. When the user asks to find `Publish`, `Save`, `Save Draft`, or `Preview`, search the current editor state first and scope the action to the visible editor surface.
+8. If the user did not explicitly ask to publish, do not publish unless the profile is configured to publish by default. When in doubt, save a draft.
+9. If login is still required after saved credentials were attempted, stop and explain what happened instead of guessing.
+10. If validation errors, blocking messages, or Orchard application errors appear, summarize them and stop instead of guessing.
+11. Do not close the browser just because one task is complete.
+12. After a task is successfully completed, explicitly ask whether the user has another task for the same browser session or wants to stop.
+13. When the user asks interactive observation questions such as `do you see X?`, `what widgets do you see?`, `can you check whether Y is visible?`, or `did that save?`, inspect the live page first using the observation tools.
+14. If the page does not clearly show the requested widget, control, or definition, say exactly that and summarize the closest visible matches.
+15. When filling body-like fields such as `HtmlBody`, respect append versus replace mode explicitly.
+16. When a field is likely hidden behind a tab, accordion, card, or section, open that container before declaring the field missing.
+17. When the task involves `FlowPart`, `BagPart`, widgets, or nested content structures, keep every action scoped to the nearest visible parent container.
+18. Do not save or publish after editing unless the user explicitly asked for save or publish, or the task clearly requires completion and the profile says to publish by default.
+19. If the user says `pause`, stop after the requested action is complete and ask what they want to do next from the current page.
+20. Do not report conflicting outcomes in the same answer. After a failed step or retry, summarize only the final verified state from the latest observation or the latest tool error.
+21. Do not claim publish succeeded unless the latest observation or structured verification evidence confirms it.
+22. If a tool returns an application or database error such as `database is locked`, say that plainly, explain that Orchard failed the save or publish request, and stop instead of retrying automatically.
+23. Follow-up instructions should continue from the current browser page whenever possible.
+24. When the user says `list them` or asks what content items are visible, use the content-list tool and report the visible titles directly.
+25. When the user gives an approximate title or says `use your best judgment`, choose the best visible or searchable title match, state which title you matched, and continue unless the page evidence is too weak.
+26. When the user asks about content types, parts, fields, or definitions and no dedicated tool exists, move cautiously through the admin UI, capture state after each navigation step, and report exactly which definition screen you reached.
+27. Use screenshots after important state changes, before risky follow-up debugging, and when the user asks for visual confirmation.
+28. When an OrchardCore action such as `Edit`, `Publish Now`, `Save Draft`, `Preview`, `Delete`, `Clone`, or a widget action cannot be located through normal inspection tools, call `playwright_diagnose_orchard_action` with the exact action label before declaring the action missing. Report the structured evidence from the result including the attempted locator strategies, any captured screenshots, and the current page URL and title so the user can understand why the action was not found.
+
+Success criteria:
+
+- the requested OrchardCore admin action is completed
+- the correct content item, definition, widget, or field was targeted
+- the requested title or target value matches exactly
+- the final observation confirms completion through URL, heading, toast, status, visible widget state, page content, or screenshot evidence
+- after task completion, the assistant asks whether there is another task for the same browser session
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Playwright/BackgroundTasks/PlaywrightSessionCleanupBackgroundTask.cs b/src/Modules/CrestApps.OrchardCore.AI.Playwright/BackgroundTasks/PlaywrightSessionCleanupBackgroundTask.cs
new file mode 100644
index 000000000..1b44f6936
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Playwright/BackgroundTasks/PlaywrightSessionCleanupBackgroundTask.cs
@@ -0,0 +1,24 @@
+using CrestApps.OrchardCore.AI.Playwright.Services;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using OrchardCore.BackgroundTasks;
+
+namespace CrestApps.OrchardCore.AI.Playwright.BackgroundTasks;
+
+[BackgroundTask(
+ Title = "Playwright Session Cleanup",
+ Schedule = "*/5 * * * *",
+ Description = "Closes inactive Playwright browser sessions after their inactivity timeout elapses.",
+ LockTimeout = 5_000,
+ LockExpiration = 300_000)]
+public sealed class PlaywrightSessionCleanupBackgroundTask : IBackgroundTask
+{
+ public async Task DoWorkAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken)
+ {
+ var logger = serviceProvider.GetRequiredService>();
+ var sessionManager = serviceProvider.GetRequiredService();
+
+ logger.LogDebug("Running Playwright session cleanup.");
+ await sessionManager.CloseInactiveSessionsAsync(cancellationToken);
+ }
+}
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Playwright/Controllers/PlaywrightSessionApiController.cs b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Controllers/PlaywrightSessionApiController.cs
new file mode 100644
index 000000000..850a4f36e
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Controllers/PlaywrightSessionApiController.cs
@@ -0,0 +1,66 @@
+using System.Security.Claims;
+using CrestApps.OrchardCore.AI.Playwright.Models;
+using CrestApps.OrchardCore.AI.Playwright.Services;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace CrestApps.OrchardCore.AI.Playwright.Controllers;
+
+///
+/// Lightweight REST surface used by the admin widget JS to query and control Playwright sessions.
+/// All endpoints require the user to be authenticated.
+///
+[ApiController]
+[Authorize]
+[Route("api/playwright")]
+public sealed class PlaywrightSessionApiController : ControllerBase
+{
+ private readonly IPlaywrightSessionManager _sessionManager;
+
+ public PlaywrightSessionApiController(IPlaywrightSessionManager sessionManager)
+ {
+ _sessionManager = sessionManager;
+ }
+
+ [HttpGet("{chatSessionId}/status")]
+ public IActionResult GetStatus(string chatSessionId)
+ {
+ var session = _sessionManager.GetSession(chatSessionId, GetOwnerId());
+
+ return session == null
+ ? Ok(PlaywrightStatusResponse.Inactive(chatSessionId))
+ : Ok(PlaywrightStatusResponse.FromSession(chatSessionId, session));
+ }
+
+ [HttpGet("active")]
+ public IActionResult GetActiveSessions()
+ {
+ var sessions = _sessionManager.GetActiveSessions(GetOwnerId());
+ var result = sessions.Select(kvp => PlaywrightStatusResponse.FromSession(kvp.Key, kvp.Value));
+
+ return Ok(result);
+ }
+
+ [HttpPost("{chatSessionId}/stop")]
+ public IActionResult Stop(string chatSessionId)
+ {
+ _sessionManager.Stop(chatSessionId, GetOwnerId());
+
+ var session = _sessionManager.GetSession(chatSessionId, GetOwnerId());
+
+ return session == null
+ ? Ok(PlaywrightStatusResponse.Inactive(chatSessionId))
+ : Ok(PlaywrightStatusResponse.FromSession(chatSessionId, session));
+ }
+
+ [HttpPost("{chatSessionId}/close")]
+ public async Task Close(string chatSessionId)
+ {
+ await _sessionManager.CloseAsync(chatSessionId, GetOwnerId());
+
+ return Ok(PlaywrightStatusResponse.Inactive(chatSessionId));
+ }
+
+ private string GetOwnerId()
+ => User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.Identity?.Name;
+}
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Playwright/CrestApps.OrchardCore.AI.Playwright.csproj b/src/Modules/CrestApps.OrchardCore.AI.Playwright/CrestApps.OrchardCore.AI.Playwright.csproj
new file mode 100644
index 000000000..34dfc38e9
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Playwright/CrestApps.OrchardCore.AI.Playwright.csproj
@@ -0,0 +1,38 @@
+
+
+
+ $(MSBuildProjectName)
+ true
+ CrestApps OrchardCore AI Playwright Module
+
+ $(CrestAppsDescription)
+
+ Enables the AI Chat Admin Widget agent to drive a real browser via Playwright,
+ allowing admins to watch, stop, and interact with browser automation tasks.
+
+ $(PackageTags) OrchardCoreCMS AI Playwright
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Playwright/Drivers/ChatInteractionPlaywrightSettingsDisplayDriver.cs b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Drivers/ChatInteractionPlaywrightSettingsDisplayDriver.cs
new file mode 100644
index 000000000..d975ba955
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Drivers/ChatInteractionPlaywrightSettingsDisplayDriver.cs
@@ -0,0 +1,36 @@
+using CrestApps.OrchardCore.AI.Models;
+using CrestApps.OrchardCore.AI.Playwright.Models;
+using CrestApps.OrchardCore.AI.Playwright.ViewModels;
+using Microsoft.Extensions.Localization;
+using OrchardCore.DisplayManagement.Handlers;
+using OrchardCore.DisplayManagement.Views;
+using OrchardCore.Entities;
+
+namespace CrestApps.OrchardCore.AI.Playwright.Drivers;
+
+internal sealed class ChatInteractionPlaywrightSettingsDisplayDriver : DisplayDriver
+{
+ internal readonly IStringLocalizer S;
+
+ public ChatInteractionPlaywrightSettingsDisplayDriver(IStringLocalizer stringLocalizer)
+ {
+ S = stringLocalizer;
+ }
+
+ public override IDisplayResult Edit(ChatInteraction interaction, BuildEditorContext context)
+ {
+ return Initialize("ChatInteractionPlaywrightSettings_Edit", model =>
+ {
+ var metadata = interaction.As() ?? new PlaywrightSessionMetadata();
+
+ model.PlaywrightEnabled = metadata.Enabled;
+ model.PlaywrightUsername = metadata.Username;
+ model.HasSavedPassword = !string.IsNullOrWhiteSpace(metadata.ProtectedPassword);
+ model.PlaywrightBaseUrl = metadata.BaseUrl;
+ model.PlaywrightAdminBaseUrl = metadata.AdminBaseUrl;
+ model.PlaywrightPersistentProfilePath = metadata.PersistentProfilePath;
+ model.PlaywrightHeadless = metadata.Headless;
+ model.PlaywrightPublishByDefault = metadata.PublishByDefault;
+ }).Location("Parameters:6#Settings;1");
+ }
+}
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Playwright/Drivers/PlaywrightProfileSettingsDisplayDriver.cs b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Drivers/PlaywrightProfileSettingsDisplayDriver.cs
new file mode 100644
index 000000000..3d14023e4
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Drivers/PlaywrightProfileSettingsDisplayDriver.cs
@@ -0,0 +1,81 @@
+using CrestApps.OrchardCore.AI.Models;
+using CrestApps.OrchardCore.AI.Playwright.Models;
+using CrestApps.OrchardCore.AI.Playwright.Settings;
+using CrestApps.OrchardCore.AI.Playwright.ViewModels;
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.Extensions.Localization;
+using OrchardCore.DisplayManagement.Handlers;
+using OrchardCore.DisplayManagement.Views;
+using OrchardCore.Entities;
+
+namespace CrestApps.OrchardCore.AI.Playwright.Drivers;
+
+///
+/// Adds Playwright configuration to the AI Profile editor.
+///
+public class PlaywrightProfileSettingsDisplayDriver : DisplayDriver
+{
+ private readonly IDataProtectionProvider _dataProtectionProvider;
+ internal readonly IStringLocalizer S;
+
+ public PlaywrightProfileSettingsDisplayDriver(
+ IDataProtectionProvider dataProtectionProvider,
+ IStringLocalizer stringLocalizer)
+ {
+ _dataProtectionProvider = dataProtectionProvider;
+ S = stringLocalizer;
+ }
+
+ public override IDisplayResult Edit(AIProfile profile, BuildEditorContext context)
+ {
+ return Initialize("PlaywrightProfileSettings_Edit", model =>
+ {
+ var metadata = profile.As() ?? new PlaywrightSessionMetadata();
+ var legacySettings = profile.GetSettings();
+
+ model.Enabled = metadata.Enabled || legacySettings.Enabled;
+ model.Username = metadata.Username;
+ model.HasSavedPassword = !string.IsNullOrWhiteSpace(metadata.ProtectedPassword);
+ model.BaseUrl = metadata.BaseUrl;
+ model.AdminBaseUrl = metadata.AdminBaseUrl;
+ model.PersistentProfilePath = metadata.PersistentProfilePath;
+ model.Headless = metadata.Headless;
+ model.PublishByDefault = metadata.PublishByDefault;
+ }).Location("Content:50#PlaywrightBrowserAutomation;10");
+ }
+
+ public override async Task UpdateAsync(AIProfile profile, UpdateEditorContext context)
+ {
+ var model = new PlaywrightProfileSettingsViewModel();
+ await context.Updater.TryUpdateModelAsync(model, Prefix);
+
+ var existingMetadata = profile.As() ?? new PlaywrightSessionMetadata();
+ var protector = _dataProtectionProvider.CreateProtector(PlaywrightConstants.ProtectorName);
+ var protectedPassword = existingMetadata.ProtectedPassword;
+
+ if (!string.IsNullOrWhiteSpace(model.Password))
+ {
+ protectedPassword = protector.Protect(model.Password);
+ }
+
+ profile.Put(new PlaywrightSessionMetadata
+ {
+ Enabled = model.Enabled,
+ BrowserMode = PlaywrightBrowserMode.PersistentContext,
+ Username = model.Username?.Trim(),
+ ProtectedPassword = protectedPassword,
+ BaseUrl = model.BaseUrl?.Trim(),
+ AdminBaseUrl = model.AdminBaseUrl?.Trim(),
+ PersistentProfilePath = model.PersistentProfilePath?.Trim(),
+ Headless = model.Headless,
+ PublishByDefault = model.PublishByDefault,
+ });
+
+ profile.AlterSettings(settings =>
+ {
+ settings.Enabled = model.Enabled;
+ });
+
+ return Edit(profile, context);
+ }
+}
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Playwright/Filters/PlaywrightAdminWidgetFilter.cs b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Filters/PlaywrightAdminWidgetFilter.cs
new file mode 100644
index 000000000..600e244e6
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Filters/PlaywrightAdminWidgetFilter.cs
@@ -0,0 +1,60 @@
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Filters;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.Extensions.Options;
+using OrchardCore.Admin;
+using OrchardCore.DisplayManagement;
+using OrchardCore.DisplayManagement.Layout;
+
+namespace CrestApps.OrchardCore.AI.Playwright.Filters;
+
+///
+/// Injects the Playwright status bar into every admin page's Footer zone.
+/// The bar is hidden by default and shown by JS once a Playwright session becomes active.
+///
+public sealed class PlaywrightAdminWidgetFilter : IAsyncResultFilter
+{
+ private readonly ILayoutAccessor _layoutAccessor;
+ private readonly IShapeFactory _shapeFactory;
+ private readonly AdminOptions _adminOptions;
+
+ public PlaywrightAdminWidgetFilter(
+ ILayoutAccessor layoutAccessor,
+ IShapeFactory shapeFactory,
+ IOptions adminOptions)
+ {
+ _layoutAccessor = layoutAccessor;
+ _shapeFactory = shapeFactory;
+ _adminOptions = adminOptions.Value;
+ }
+
+ public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next)
+ {
+ if (!IsAdminPage(context) || context.HttpContext.User.Identity?.IsAuthenticated != true)
+ {
+ await next();
+ return;
+ }
+
+ var shape = await _shapeFactory.CreateAsync("PlaywrightAdminWidget");
+
+ var layout = await _layoutAccessor.GetLayoutAsync();
+
+ // Place just after the AI chat widget (which is at "999").
+ await layout.Zones["Footer"].AddAsync(shape, "1000");
+
+ await next();
+ }
+
+ private bool IsAdminPage(ResultExecutingContext context)
+ {
+ if (context.Result is not (ViewResult or PageResult))
+ {
+ return false;
+ }
+
+ return context.HttpContext.Request.Path.StartsWithSegments(
+ '/' + _adminOptions.AdminUrlPrefix,
+ StringComparison.OrdinalIgnoreCase);
+ }
+}
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Playwright/FunctionTools.md b/src/Modules/CrestApps.OrchardCore.AI.Playwright/FunctionTools.md
new file mode 100644
index 000000000..5bef81367
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Playwright/FunctionTools.md
@@ -0,0 +1,165 @@
+# OrchardCore Playwright Skills and Function Tools
+
+This file defines the OrchardCore-specific Playwright tool surface for `CrestApps.OrchardCore.AI.Playwright`.
+
+Keep the layers separate:
+
+- Skill: the AI playbook for an OrchardCore workflow.
+- Tool: the deterministic OrchardCore action exposed to the AI.
+- Service: the reusable C# implementation behind the tool.
+- Tenant or app: the orchestrator that loads the right OrchardCore skill and tool set for the current session.
+
+## Operating Principles
+
+- Treat OrchardCore admin as a structured CMS, not a generic website.
+- Expose OrchardCore-specific tools only.
+- Keep generic browser click and fill behavior inside services, not in the public AI tool surface.
+- Verify every mutating step with page state, toast, heading, URL, status, or screenshot evidence.
+- Scope actions to OrchardCore structures such as content rows, editor tabs, fieldsets, cards, summaries, and widget containers.
+- Continue from the current page whenever possible.
+
+## Current Tool Architecture
+
+These are the OrchardCore tools that should remain exposed to the AI.
+
+| Tool | Primary C# service | Use for | Verify after call |
+| --- | --- | --- | --- |
+| `playwright_capture_state` | `IOrchardAdminPlaywrightService.CaptureStateAsync()` | Ground the next step from the live page before risky actions | URL, title, heading, toast, validation messages, visible buttons |
+| `playwright_open_admin_home` | `IOrchardAdminPlaywrightService.OpenAdminHomeAsync()` | Ensure the session is inside the correct Orchard admin shell | Admin URL, authenticated state, admin heading |
+| `playwright_open_content_items` | `IOrchardAdminPlaywrightService.OpenContentItemsAsync()` | Move to the content items list without guessing routes | Content list heading and URL |
+| `playwright_list_content_items` | `IOrchardAdminPlaywrightService.ListVisibleContentItemsAsync()` | Inspect visible content rows, titles, types, status, and edit availability | Returned titles, status text, content type, item count |
+| `playwright_open_content_item_editor` | `IOrchardAdminPlaywrightService.OpenContentItemEditorAsync()` | Open the correct content item by title using row-scoped actions | Matched title, editor URL, editor heading, post-open state |
+| `playwright_open_new_content_item` | `IOrchardAdminPlaywrightService.OpenNewContentItemAsync()` | Start the create flow for a content type from the admin list | Editor heading and title field visibility |
+| `playwright_open_editor_tab` | `IOrchardAdminPlaywrightService.OpenEditorTabAsync()` | Open Orchard editor tabs, accordion sections, summaries, and visible editor panels by name | Matched tab or section and resulting editor state |
+| `playwright_set_content_title` | `IOrchardAdminPlaywrightService.SetContentTitleAsync()` | Set the Orchard title field reliably | Updated editor state and visible title input |
+| `playwright_set_field_value` | `IOrchardAdminPlaywrightService.SetFieldValueAsync()` | Update typed Orchard fields such as text, textarea, select, and checkbox | Field result plus updated observation |
+| `playwright_set_body_field` | `IOrchardAdminPlaywrightService.SetBodyFieldAsync()` | Update body-like Orchard fields with append or replace semantics | Resolved editor type and updated observation |
+| `playwright_save_draft` | `IOrchardAdminPlaywrightService.SaveDraftAsync()` | Save without publishing | Toast, URL, heading, status message |
+| `playwright_publish_content` | `IOrchardAdminPlaywrightService.PublishContentAsync()` | Publish the current content item when the user asked for publish | Toast, URL, heading, and post-publish page state |
+| `playwright_publish_and_verify` | `IOrchardAdminPlaywrightService.PublishAndVerifyAsync()` | Publish the current content item and return structured Orchard verification evidence | Verification signals and final observation |
+| `playwright_get_page_content` | `IPlaywrightPageInspectionService.GetPageContentAsync()` | Read the visible page when answering live UI questions | Visible content and main heading |
+| `playwright_find_element` | `IPlaywrightPageInspectionService.FindElementsAsync()` | Locate a control, field, widget, tab, or text snippet from the current page | Match list with text, role, label, selector hints |
+| `playwright_check_element_exists` | `IPlaywrightPageInspectionService.CheckElementExistsAsync()` | Confirm whether a target widget, field, or control is visible | Boolean existence and closest matches |
+| `playwright_get_visible_widgets` | `IPlaywrightPageInspectionService.GetVisibleWidgetsAsync()` | List visible widget-like cards, headings, legends, and sections | Widget names and source hints |
+| `playwright_take_screenshot` | `IPlaywrightPageInspectionService.TakeScreenshotAsync()` | Capture evidence for validation, debugging, and user confirmation | Saved path, timestamp, page URL, screenshot scope |
+| `playwright_diagnose_orchard_action` | `IOrchardEvidenceService.FindOrchardElementWithEvidenceAsync()` | Locate a named OrchardCore action using 5-tier priority locator strategies; captures full evidence on failure | Found, MatchedLocator, PageScreenshotPath, ContainerScreenshotPath, PageHtmlPath, Attempts |
+
+## OrchardCore Skill Catalog
+
+The AI should reason in Orchard skills, then call deterministic Orchard tools.
+
+| Skill name | Covers | Current tool entry points | Recommended next C# surface |
+| --- | --- | --- | --- |
+| `orchard-admin-navigation` | Admin shell navigation, tenant-aware routes, admin menu traversal, and safe return-to-context behavior | `playwright_capture_state`, `playwright_open_admin_home`, `playwright_open_content_items` | `IOrchardAdminNavigationService.OpenAreaAsync()`, `OpenSectionAsync()` |
+| `orchard-content-items` | Content item lists, row actions, search, title matching, status inspection, and item opening | `playwright_open_content_items`, `playwright_list_content_items`, `playwright_open_content_item_editor` | `IOrchardContentListService.OpenListAsync()`, `FindRowAsync()`, `InvokeRowActionAsync()` |
+| `orchard-content-editor` | Title editing, typed field editing, editor tab switching, body-field handling, and pause-before-save behavior | `playwright_open_editor_tab`, `playwright_set_content_title`, `playwright_set_field_value`, `playwright_set_body_field` | `IOrchardContentEditorService.OpenTabAsync()`, `SetFieldAsync()`, `SetBodyFieldAsync()` |
+| `orchard-publish-verification` | Save, publish, status confirmation, validation handling, and final evidence | `playwright_save_draft`, `playwright_publish_content`, `playwright_publish_and_verify`, `playwright_take_screenshot` | `IOrchardContentWorkflowService.SaveAndVerifyAsync()`, `PublishAndVerifyAsync()` |
+| `orchard-widget-tree` | FlowPart, BagPart, widgets, nested cards, expanders, repeated components, and scoped edits inside widget containers | `playwright_get_visible_widgets`, `playwright_find_element`, `playwright_check_element_exists` | `IOrchardWidgetEditorService.FindWidgetAsync()`, `OpenWidgetAsync()`, `AddWidgetAsync()`, `EditWidgetFieldAsync()`, `MoveWidgetAsync()` |
+| `orchard-content-definitions` | Content types, parts, fields, definitions screens, and admin metadata workflows | `playwright_capture_state`, `playwright_open_admin_home`, `playwright_find_element`, `playwright_take_screenshot` | `IOrchardDefinitionAdminService.OpenContentTypesAsync()`, `OpenTypeAsync()`, `OpenPartAsync()`, `OpenFieldAsync()` |
+| `orchard-screenshot-diagnostics` | Screenshots, page-state confirmation, evidence capture, UI debugging, and failure reporting | `playwright_capture_state`, `playwright_get_page_content`, `playwright_take_screenshot`, `playwright_find_element`, `playwright_diagnose_orchard_action` | `IOrchardEvidenceService` ✓ implemented |
+
+## Selector Strategy for OrchardCore
+
+Use selectors in this order:
+
+1. Use a dedicated OrchardCore workflow tool.
+2. Use OrchardCore structural scoping inside the service layer.
+3. Use accessible names and labels inside the service layer.
+4. Do not expose raw browser selectors to the AI.
+
+### Stable OrchardCore selector tiers
+
+| Tier | Strategy | OrchardCore examples |
+| --- | --- | --- |
+| Tier 1 | Route-aware task tools | Open admin home, open content list, open item by title, open editor tab, save draft, publish |
+| Tier 2 | OrchardCore structural selectors | `tbody tr`, `[data-content-item-id]`, `.content-item`, `.list-group-item`, `.card`, `.card-header`, `.card-title`, `fieldset > legend`, `details > summary` |
+| Tier 3 | OrchardCore field fallbacks | `input[name='TitlePart.Title']`, `input[id*='TitlePart_Title']`, visible `textarea`, `select`, `input[type='checkbox']`, `[contenteditable='true']`, rich editor surface, iframe body |
+| Tier 4 | Internal service heuristics only | Accessible-name and label matching, then Orchard-aware related-field discovery |
+
+### OrchardCore-specific selector guidance
+
+- Content item list rows:
+ - Scope to the row or container that contains the requested title before clicking `Edit`, `Publish`, `Preview`, `Delete`, or `Clone`.
+ - Use title, then content type, then status to disambiguate repeated names.
+- Editor tabs and sections:
+ - Prefer real tabs first, then buttons, links, summaries, card headers, card titles, and legends.
+ - Treat already-visible sections as valid matches instead of forcing an unnecessary click.
+- Title field:
+ - Prefer label `Title`.
+ - Fallback to `input[name='TitlePart.Title']`, `input[id*='TitlePart_Title']`, or Orchard title input variants.
+- Typed fields:
+ - Text, textarea, checkbox, and select should be handled by Orchard-specific field logic instead of a generic fill tool.
+- Body-like fields:
+ - Detect textarea, TinyMCE, contenteditable, and iframe editors.
+ - Support append and replace modes explicitly.
+- Validation and status:
+ - Check toast messages, alerts, validation summaries, field validation messages, and visible status badges before claiming success.
+
+## Reusable OrchardCore Workflow Patterns
+
+### Find the Edit button for a specific content item
+
+1. Call `playwright_open_content_items`.
+2. Call `playwright_list_content_items` if the visible list is needed for grounding or matching.
+3. Call `playwright_open_content_item_editor` with the requested title.
+4. If the match is weak, report the closest titles instead of editing the wrong row.
+
+### Open a content item and update fields
+
+1. Open the editor by exact or best visible title match.
+2. Call `playwright_open_editor_tab` if the field lives inside another tab or collapsible section.
+3. Use `playwright_set_content_title`, `playwright_set_field_value`, and `playwright_set_body_field` for Orchard-specific edits.
+4. Stop after the edit unless the user also asked to save or publish.
+
+### Publish or save and verify state
+
+1. Execute `playwright_save_draft`, `playwright_publish_content`, or `playwright_publish_and_verify`.
+2. Capture state or screenshot immediately if verification is ambiguous.
+3. Confirm success from the latest URL, heading, toast, status text, or structured verification signals.
+4. If evidence is weak, report that verification is incomplete instead of guessing.
+
+### Work with FlowPart, BagPart, and nested widgets
+
+1. Capture state and list visible widgets before editing.
+2. Identify the parent editor section, then the target widget card or repeated item.
+3. Expand collapsed cards or summaries before searching for fields.
+4. Scope every action to the current widget container.
+5. If the current tool layer cannot target the widget deterministically, stop and use the recommended widget-specific next-wave tools instead of guessing.
+
+### Confirm final UI state
+
+1. Use `playwright_capture_state` for structured confirmation.
+2. Use `playwright_get_page_content` when the user needs textual proof.
+3. Use `playwright_take_screenshot` for visual proof or ambiguous UI.
+4. Report the verified final state only, not intermediate guesses.
+
+## Recommended Next-Wave OrchardCore Tools
+
+These should be added as OrchardCore-specific tools instead of reintroducing generic browser actions.
+
+| Proposed tool | Recommended service call | Why it matters |
+| --- | --- | --- |
+| `playwright_open_content_definition` | `IOrchardDefinitionAdminService.OpenContentTypesAsync()` | Definitions are a different admin workflow than content items |
+| `playwright_open_content_type_editor` | `IOrchardDefinitionAdminService.OpenTypeAsync(string typeName)` | Lets the AI work with type definitions safely |
+| `playwright_find_widget` | `IOrchardWidgetEditorService.FindWidgetAsync(string widgetName, string widgetType, string parentPath)` | Required for FlowPart and BagPart depth |
+| `playwright_open_widget_editor` | `IOrchardWidgetEditorService.OpenWidgetAsync(...)` | Stable entry point for nested widget editing |
+| `playwright_add_widget` | `IOrchardWidgetEditorService.AddWidgetAsync(string containerName, string widgetType)` | Supports real Orchard widget authoring workflows |
+| `playwright_validate_content_status` | `IOrchardContentWorkflowService.VerifyStatusAsync(string expectedStatus)` | Needed for production-grade status checking |
+| ~~`playwright_capture_evidence`~~ | Implemented as `playwright_diagnose_orchard_action` / `IOrchardEvidenceService` | Now ships as a production tool — see Current Tool Architecture above |
+
+## Production-Ready Service Structure
+
+Keep the Orchard admin service as the current Orchard automation facade, but split deeper logic behind focused services as the tool set grows:
+
+- `IOrchardPageClassifier`
+- `OrchardAdminSelectorCatalog`
+- `IOrchardAdminNavigationService`
+- `IOrchardContentListService`
+- `IOrchardContentEditorService`
+- `IOrchardContentWorkflowService`
+- `IOrchardWidgetEditorService`
+- `IOrchardDefinitionAdminService`
+- `IOrchardEvidenceService` ✓ (`OrchardEvidenceService` — `FindOrchardElementWithEvidenceAsync`)
+- `IPlaywrightPageInspectionService`
+
+This keeps the AI tool layer Orchard-specific while preventing the service layer from collapsing back into generic browser automation.
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Playwright/Handlers/PlaywrightChatInteractionSettingsHandler.cs b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Handlers/PlaywrightChatInteractionSettingsHandler.cs
new file mode 100644
index 000000000..9076da4ec
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Handlers/PlaywrightChatInteractionSettingsHandler.cs
@@ -0,0 +1,74 @@
+using System.Text.Json;
+using CrestApps.OrchardCore.AI;
+using CrestApps.OrchardCore.AI.Models;
+using CrestApps.OrchardCore.AI.Playwright.Models;
+using Microsoft.AspNetCore.DataProtection;
+using OrchardCore.Entities;
+
+namespace CrestApps.OrchardCore.AI.Playwright.Handlers;
+
+internal sealed class PlaywrightChatInteractionSettingsHandler : IChatInteractionSettingsHandler
+{
+ private readonly IDataProtectionProvider _dataProtectionProvider;
+
+ public PlaywrightChatInteractionSettingsHandler(IDataProtectionProvider dataProtectionProvider)
+ {
+ _dataProtectionProvider = dataProtectionProvider;
+ }
+
+ public Task UpdatingAsync(ChatInteraction interaction, JsonElement settings)
+ {
+ var existingMetadata = interaction.As() ?? new PlaywrightSessionMetadata();
+ var protector = _dataProtectionProvider.CreateProtector(PlaywrightConstants.ProtectorName);
+ var password = GetString(settings, "playwrightPassword");
+ var protectedPassword = string.IsNullOrWhiteSpace(password)
+ ? existingMetadata.ProtectedPassword
+ : protector.Protect(password);
+
+ interaction.Put(new PlaywrightSessionMetadata
+ {
+ Enabled = GetBool(settings, "playwrightEnabled"),
+ BrowserMode = PlaywrightBrowserMode.PersistentContext,
+ Username = GetString(settings, "playwrightUsername")?.Trim(),
+ ProtectedPassword = protectedPassword,
+ BaseUrl = GetString(settings, "playwrightBaseUrl")?.Trim(),
+ AdminBaseUrl = GetString(settings, "playwrightAdminBaseUrl")?.Trim(),
+ PersistentProfilePath = GetString(settings, "playwrightPersistentProfilePath")?.Trim(),
+ Headless = GetBool(settings, "playwrightHeadless"),
+ PublishByDefault = GetBool(settings, "playwrightPublishByDefault"),
+ });
+
+ return Task.CompletedTask;
+ }
+
+ public Task UpdatedAsync(ChatInteraction interaction, JsonElement settings)
+ => Task.CompletedTask;
+
+ private static string GetString(JsonElement element, string propertyName)
+ {
+ if (element.TryGetProperty(propertyName, out var prop) && prop.ValueKind == JsonValueKind.String)
+ {
+ return prop.GetString();
+ }
+
+ return null;
+ }
+
+ private static bool GetBool(JsonElement element, string propertyName)
+ {
+ if (element.TryGetProperty(propertyName, out var prop))
+ {
+ if (prop.ValueKind == JsonValueKind.True)
+ {
+ return true;
+ }
+
+ if (prop.ValueKind == JsonValueKind.String)
+ {
+ return string.Equals(prop.GetString(), "true", StringComparison.OrdinalIgnoreCase);
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Playwright/Handlers/PlaywrightFeatureEventHandler.cs b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Handlers/PlaywrightFeatureEventHandler.cs
new file mode 100644
index 000000000..046343503
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Handlers/PlaywrightFeatureEventHandler.cs
@@ -0,0 +1,71 @@
+using Microsoft.Extensions.Logging;
+using OrchardCore.Environment.Extensions.Features;
+using OrchardCore.Environment.Shell;
+
+// IFeatureInfo lives in OrchardCore.Environment.Extensions.Features.
+// IFeatureEventHandler (the one registered in DI) lives in OrchardCore.Environment.Shell.
+
+namespace CrestApps.OrchardCore.AI.Playwright.Handlers;
+
+///
+/// Installs Playwright browser binaries the first time the feature is enabled.
+/// This ensures Chromium is available before any session is created.
+///
+public sealed class PlaywrightFeatureEventHandler : IFeatureEventHandler
+{
+ private readonly ILogger _logger;
+
+ public PlaywrightFeatureEventHandler(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ public Task InstallingAsync(IFeatureInfo feature) => Task.CompletedTask;
+ public Task InstalledAsync(IFeatureInfo feature) => Task.CompletedTask;
+ public Task EnablingAsync(IFeatureInfo feature) => Task.CompletedTask;
+
+ public Task EnabledAsync(IFeatureInfo feature)
+ {
+ if (!feature.Id.Equals(PlaywrightConstants.Feature.AdminWidget, StringComparison.OrdinalIgnoreCase))
+ {
+ return Task.CompletedTask;
+ }
+
+ return InstallBrowsersAsync();
+ }
+
+ public Task DisablingAsync(IFeatureInfo feature) => Task.CompletedTask;
+ public Task DisabledAsync(IFeatureInfo feature) => Task.CompletedTask;
+ public Task UninstallingAsync(IFeatureInfo feature) => Task.CompletedTask;
+ public Task UninstalledAsync(IFeatureInfo feature) => Task.CompletedTask;
+
+ private Task InstallBrowsersAsync()
+ {
+ return Task.Run(() =>
+ {
+ try
+ {
+ _logger.LogDebug("Installing Playwright browser binaries (chromium)...");
+
+ var exitCode = Microsoft.Playwright.Program.Main(["install", "chromium"]);
+
+ if (exitCode != 0)
+ {
+ _logger.LogDebug(
+ "Playwright browser installation exited with code {ExitCode}. " +
+ "You may need to run 'playwright install chromium' manually.", exitCode);
+ }
+ else
+ {
+ _logger.LogDebug("Playwright browser binaries installed successfully.");
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex,
+ "Failed to install Playwright browser binaries. " +
+ "Run 'playwright install chromium' manually before using browser automation.");
+ }
+ });
+ }
+}
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Playwright/Handlers/PlaywrightOrchestrationContextHandler.cs b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Handlers/PlaywrightOrchestrationContextHandler.cs
new file mode 100644
index 000000000..d980ebb20
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Handlers/PlaywrightOrchestrationContextHandler.cs
@@ -0,0 +1,76 @@
+using CrestApps.AI.Prompting.Services;
+using CrestApps.OrchardCore.AI;
+using CrestApps.OrchardCore.AI.Models;
+using CrestApps.OrchardCore.AI.Playwright.Models;
+using Microsoft.Extensions.Logging;
+using OrchardCore.Entities;
+
+namespace CrestApps.OrchardCore.AI.Playwright.Handlers;
+
+internal sealed class PlaywrightOrchestrationContextHandler : IOrchestrationContextBuilderHandler
+{
+ private readonly IAITemplateService _templateService;
+ private readonly ILogger _logger;
+
+ public PlaywrightOrchestrationContextHandler(
+ IAITemplateService templateService,
+ ILogger logger)
+ {
+ _templateService = templateService;
+ _logger = logger;
+ }
+
+ public async Task BuildingAsync(OrchestrationContextBuildingContext context)
+ {
+ if (context.Resource is not Entity entity)
+ {
+ return;
+ }
+
+ var metadata = entity.As();
+ if (metadata is null || !metadata.Enabled)
+ {
+ return;
+ }
+
+ context.Context.Properties[nameof(PlaywrightSessionMetadata)] = metadata;
+
+ try
+ {
+ var prompt = await _templateService.RenderAsync(
+ PlaywrightConstants.PromptIds.Operator,
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["baseUrl"] = metadata.BaseUrl ?? string.Empty,
+ ["adminBaseUrl"] = metadata.AdminBaseUrl ?? string.Empty,
+ ["publishBehavior"] = metadata.PublishByDefault
+ ? "Publish by default unless the user explicitly asks for a draft."
+ : "Save a draft by default unless the user explicitly asks to publish.",
+ });
+
+ if (!string.IsNullOrWhiteSpace(prompt))
+ {
+ context.Context.SystemMessageBuilder.AppendLine(prompt.Trim());
+ context.Context.SystemMessageBuilder.AppendLine();
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Unable to render the Playwright operator prompt.");
+ }
+ }
+
+ public Task BuiltAsync(OrchestrationContextBuiltContext context)
+ {
+ if (context.OrchestrationContext.CompletionContext is null ||
+ !context.OrchestrationContext.Properties.TryGetValue(nameof(PlaywrightSessionMetadata), out var metadataObject) ||
+ metadataObject is not PlaywrightSessionMetadata metadata)
+ {
+ return Task.CompletedTask;
+ }
+
+ context.OrchestrationContext.CompletionContext.AdditionalProperties[PlaywrightConstants.CompletionContextKeys.SessionMetadata] = metadata;
+
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Playwright/Manifest.cs b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Manifest.cs
new file mode 100644
index 000000000..a48727532
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Manifest.cs
@@ -0,0 +1,23 @@
+using CrestApps.OrchardCore;
+using CrestApps.OrchardCore.AI.Core;
+using CrestApps.OrchardCore.AI.Playwright;
+using OrchardCore.Modules.Manifest;
+
+[assembly: Module(
+ Name = "Artificial Intelligence",
+ Author = CrestAppsManifestConstants.Author,
+ Website = CrestAppsManifestConstants.Website,
+ Version = CrestAppsManifestConstants.Version
+)]
+
+[assembly: Feature(
+ Id = PlaywrightConstants.Feature.AdminWidget,
+ Name = "AI Playwright Browser Automation",
+ Description = "Enables the AI Chat Admin Widget agent to drive a real browser via Playwright. Admins can watch, stop, and close browser automation tasks directly from the chat widget.",
+ Category = "Artificial Intelligence",
+ Dependencies =
+ [
+ AIConstants.Feature.ChatAdminWidget,
+ AIConstants.Feature.OrchardCoreAIAgent,
+ ]
+)]
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightBrowserMode.cs b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightBrowserMode.cs
new file mode 100644
index 000000000..34ecb3624
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightBrowserMode.cs
@@ -0,0 +1,8 @@
+namespace CrestApps.OrchardCore.AI.Playwright.Models;
+
+public enum PlaywrightBrowserMode
+{
+ ConnectOverCdp = 0,
+ PersistentContext = 1,
+ FreshBrowser = 2,
+}
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightContentItemOpenResult.cs b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightContentItemOpenResult.cs
new file mode 100644
index 000000000..7fccc7da1
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightContentItemOpenResult.cs
@@ -0,0 +1,16 @@
+namespace CrestApps.OrchardCore.AI.Playwright.Models;
+
+public sealed class PlaywrightContentItemOpenResult
+{
+ public string RequestedTitle { get; init; }
+
+ public string MatchedTitle { get; init; }
+
+ public string MatchMode { get; init; }
+
+ public bool UsedSearch { get; init; }
+
+ public IReadOnlyList ClosestTitles { get; init; } = [];
+
+ public PlaywrightObservation Observation { get; init; }
+}
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightContentListItem.cs b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightContentListItem.cs
new file mode 100644
index 000000000..2795d3b4f
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightContentListItem.cs
@@ -0,0 +1,12 @@
+namespace CrestApps.OrchardCore.AI.Playwright.Models;
+
+public sealed class PlaywrightContentListItem
+{
+ public string Title { get; init; }
+
+ public string ContentType { get; init; }
+
+ public string Status { get; init; }
+
+ public bool CanEdit { get; init; }
+}
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightContentListResult.cs b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightContentListResult.cs
new file mode 100644
index 000000000..22274c392
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightContentListResult.cs
@@ -0,0 +1,14 @@
+namespace CrestApps.OrchardCore.AI.Playwright.Models;
+
+public sealed class PlaywrightContentListResult
+{
+ public string Url { get; init; }
+
+ public string Title { get; init; }
+
+ public string MainHeading { get; init; }
+
+ public int ItemCount { get; init; }
+
+ public IReadOnlyList Items { get; init; } = [];
+}
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightEditorActionResult.cs b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightEditorActionResult.cs
new file mode 100644
index 000000000..b50c220b0
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightEditorActionResult.cs
@@ -0,0 +1 @@
+// Retired while converging on the Orchard admin service result types.
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightEditorTargetResult.cs b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightEditorTargetResult.cs
new file mode 100644
index 000000000..7e779a255
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightEditorTargetResult.cs
@@ -0,0 +1,12 @@
+namespace CrestApps.OrchardCore.AI.Playwright.Models;
+
+public sealed class PlaywrightEditorTargetResult
+{
+ public string RequestedTarget { get; init; }
+
+ public string MatchedTarget { get; init; }
+
+ public string TargetKind { get; init; }
+
+ public PlaywrightObservation Observation { get; init; }
+}
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightElementDiagnosticsResult.cs b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightElementDiagnosticsResult.cs
new file mode 100644
index 000000000..ef041a8d7
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightElementDiagnosticsResult.cs
@@ -0,0 +1,43 @@
+namespace CrestApps.OrchardCore.AI.Playwright.Models;
+
+public sealed class PlaywrightElementDiagnosticsResult
+{
+ ///
+ /// True when at least one locator strategy matched a visible element.
+ ///
+ public bool Found { get; init; }
+
+ ///
+ /// Name of the locator strategy that matched, e.g. "role:link[Edit]".
+ /// Null when is false.
+ ///
+ public string MatchedLocator { get; init; }
+
+ /// Current page URL at the time of the search.
+ public string Url { get; init; }
+
+ /// Current page title at the time of the search.
+ public string Title { get; init; }
+
+ ///
+ /// Full-page screenshot path. Populated only on failure to help diagnose the missing action.
+ ///
+ public string PageScreenshotPath { get; init; }
+
+ ///
+ /// Screenshot of the nearest relevant OrchardCore container found on the page.
+ /// Populated only on failure when a candidate container is located.
+ ///
+ public string ContainerScreenshotPath { get; init; }
+
+ ///
+ /// Path to the saved raw page HTML. Populated only on failure.
+ ///
+ public string PageHtmlPath { get; init; }
+
+ ///
+ /// Ordered list of locator strategy descriptions that were attempted.
+ /// Always populated so the AI can report exactly what was tried.
+ ///
+ public IReadOnlyList Attempts { get; init; } = [];
+}
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightElementMatch.cs b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightElementMatch.cs
new file mode 100644
index 000000000..22a97418c
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightElementMatch.cs
@@ -0,0 +1,24 @@
+namespace CrestApps.OrchardCore.AI.Playwright.Models;
+
+public sealed class PlaywrightElementMatch
+{
+ public string TagName { get; init; }
+
+ public string Role { get; init; }
+
+ public string Text { get; init; }
+
+ public string Label { get; init; }
+
+ public string Id { get; init; }
+
+ public string Name { get; init; }
+
+ public string Title { get; init; }
+
+ public string Placeholder { get; init; }
+
+ public string WidgetType { get; init; }
+
+ public string SelectorHint { get; init; }
+}
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightElementSearchResult.cs b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightElementSearchResult.cs
new file mode 100644
index 000000000..44a04b079
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightElementSearchResult.cs
@@ -0,0 +1,16 @@
+namespace CrestApps.OrchardCore.AI.Playwright.Models;
+
+public sealed class PlaywrightElementSearchResult
+{
+ public string Query { get; init; }
+
+ public string Url { get; init; }
+
+ public string Title { get; init; }
+
+ public bool Exists { get; init; }
+
+ public int MatchCount { get; init; }
+
+ public IReadOnlyList Matches { get; init; } = [];
+}
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightFieldEditResult.cs b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightFieldEditResult.cs
new file mode 100644
index 000000000..7dc460518
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightFieldEditResult.cs
@@ -0,0 +1,14 @@
+namespace CrestApps.OrchardCore.AI.Playwright.Models;
+
+public sealed class PlaywrightFieldEditResult
+{
+ public string Label { get; init; }
+
+ public string RequestedFieldType { get; init; }
+
+ public string RequestedEditMode { get; init; }
+
+ public string ResolvedFieldType { get; init; }
+
+ public PlaywrightObservation Observation { get; init; }
+}
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightObservation.cs b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightObservation.cs
new file mode 100644
index 000000000..79d2df8ed
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightObservation.cs
@@ -0,0 +1,20 @@
+namespace CrestApps.OrchardCore.AI.Playwright.Models;
+
+public sealed class PlaywrightObservation
+{
+ public string CurrentUrl { get; init; }
+
+ public string PageTitle { get; init; }
+
+ public string MainHeading { get; init; }
+
+ public string ToastMessage { get; init; }
+
+ public IReadOnlyList ValidationMessages { get; init; } = [];
+
+ public IReadOnlyList VisibleButtons { get; init; } = [];
+
+ public bool IsLoginPage { get; init; }
+
+ public bool IsAuthenticated { get; init; }
+}
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightPageContentResult.cs b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightPageContentResult.cs
new file mode 100644
index 000000000..518888e78
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightPageContentResult.cs
@@ -0,0 +1,12 @@
+namespace CrestApps.OrchardCore.AI.Playwright.Models;
+
+public sealed class PlaywrightPageContentResult
+{
+ public string Url { get; init; }
+
+ public string Title { get; init; }
+
+ public string MainHeading { get; init; }
+
+ public string Content { get; init; }
+}
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightPublishVerificationResult.cs b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightPublishVerificationResult.cs
new file mode 100644
index 000000000..9a2e015bd
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightPublishVerificationResult.cs
@@ -0,0 +1,14 @@
+namespace CrestApps.OrchardCore.AI.Playwright.Models;
+
+public sealed class PlaywrightPublishVerificationResult
+{
+ public string Action { get; init; }
+
+ public string ExpectedStatus { get; init; }
+
+ public bool Verified { get; init; }
+
+ public IReadOnlyList VerificationSignals { get; init; } = [];
+
+ public PlaywrightObservation Observation { get; init; }
+}
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightScreenshotResult.cs b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightScreenshotResult.cs
new file mode 100644
index 000000000..69a35537f
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightScreenshotResult.cs
@@ -0,0 +1,14 @@
+namespace CrestApps.OrchardCore.AI.Playwright.Models;
+
+public sealed class PlaywrightScreenshotResult
+{
+ public string Url { get; init; }
+
+ public string Title { get; init; }
+
+ public string SavedPath { get; init; }
+
+ public bool FullPage { get; init; }
+
+ public DateTime TakenAtUtc { get; init; }
+}
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightSessionMetadata.cs b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightSessionMetadata.cs
new file mode 100644
index 000000000..5d6bff467
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightSessionMetadata.cs
@@ -0,0 +1,28 @@
+namespace CrestApps.OrchardCore.AI.Playwright.Models;
+
+///
+/// Shared Playwright session metadata that can be attached to either an AI profile
+/// or a chat interaction via EntityExtensions.Put/As.
+///
+public sealed class PlaywrightSessionMetadata
+{
+ public bool Enabled { get; set; }
+
+ public PlaywrightBrowserMode BrowserMode { get; set; } = PlaywrightBrowserMode.PersistentContext;
+
+ public string Username { get; set; }
+
+ public string ProtectedPassword { get; set; }
+
+ public string BaseUrl { get; set; }
+
+ public string AdminBaseUrl { get; set; }
+
+ public string CdpEndpoint { get; set; }
+
+ public string PersistentProfilePath { get; set; }
+
+ public bool Headless { get; set; }
+
+ public bool PublishByDefault { get; set; }
+}
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightSessionRequest.cs b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightSessionRequest.cs
new file mode 100644
index 000000000..369de2f2c
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightSessionRequest.cs
@@ -0,0 +1,32 @@
+namespace CrestApps.OrchardCore.AI.Playwright.Models;
+
+public sealed class PlaywrightSessionRequest
+{
+ public string ChatSessionId { get; init; }
+
+ public string OwnerId { get; init; }
+
+ public string ResourceItemId { get; init; }
+
+ public string BaseUrl { get; init; }
+
+ public string AdminBaseUrl { get; init; }
+
+ public PlaywrightBrowserMode BrowserMode { get; init; }
+
+ public string Username { get; init; }
+
+ public string Password { get; init; }
+
+ public bool CanAttemptLogin { get; init; }
+
+ public string CdpEndpoint { get; init; }
+
+ public string PersistentProfilePath { get; init; }
+
+ public bool Headless { get; init; }
+
+ public bool PublishByDefault { get; init; }
+
+ public int SessionInactivityTimeoutInMinutes { get; init; } = PlaywrightConstants.DefaultSessionInactivityTimeoutInMinutes;
+}
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightStatusResponse.cs b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightStatusResponse.cs
new file mode 100644
index 000000000..5b3221e5f
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightStatusResponse.cs
@@ -0,0 +1,42 @@
+using CrestApps.OrchardCore.AI.Playwright.Services;
+
+namespace CrestApps.OrchardCore.AI.Playwright.Models;
+
+/// Status payload returned by the session API endpoints.
+public sealed class PlaywrightStatusResponse
+{
+ public string ChatSessionId { get; init; }
+
+ public string Status { get; init; }
+
+ public string CurrentUrl { get; init; }
+
+ public string CurrentPageTitle { get; init; }
+
+ public string BrowserMode { get; init; }
+
+ public bool IsActive { get; init; }
+
+ public bool IsAuthenticated { get; init; }
+
+ public static PlaywrightStatusResponse FromSession(string chatSessionId, IPlaywrightSession session)
+ {
+ return new PlaywrightStatusResponse
+ {
+ ChatSessionId = chatSessionId,
+ Status = session.Status.ToString(),
+ CurrentUrl = session.CurrentUrl,
+ CurrentPageTitle = session.CurrentPageTitle,
+ BrowserMode = "Dedicated browser",
+ IsActive = session.Status != PlaywrightSessionStatus.Closed,
+ IsAuthenticated = session.IsAuthenticated,
+ };
+ }
+
+ public static PlaywrightStatusResponse Inactive(string chatSessionId) => new()
+ {
+ ChatSessionId = chatSessionId,
+ Status = PlaywrightSessionStatus.Closed.ToString(),
+ IsActive = false,
+ };
+}
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightVisibleWidget.cs b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightVisibleWidget.cs
new file mode 100644
index 000000000..8eaa11966
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightVisibleWidget.cs
@@ -0,0 +1,8 @@
+namespace CrestApps.OrchardCore.AI.Playwright.Models;
+
+public sealed class PlaywrightVisibleWidget
+{
+ public string Name { get; init; }
+
+ public string Source { get; init; }
+}
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightVisibleWidgetsResult.cs b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightVisibleWidgetsResult.cs
new file mode 100644
index 000000000..58c82f8ee
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Models/PlaywrightVisibleWidgetsResult.cs
@@ -0,0 +1,12 @@
+namespace CrestApps.OrchardCore.AI.Playwright.Models;
+
+public sealed class PlaywrightVisibleWidgetsResult
+{
+ public string Url { get; init; }
+
+ public string Title { get; init; }
+
+ public int WidgetCount { get; init; }
+
+ public IReadOnlyList Widgets { get; init; } = [];
+}
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Playwright/PlaywrightConstants.cs b/src/Modules/CrestApps.OrchardCore.AI.Playwright/PlaywrightConstants.cs
new file mode 100644
index 000000000..32c4d4acb
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Playwright/PlaywrightConstants.cs
@@ -0,0 +1,98 @@
+namespace CrestApps.OrchardCore.AI.Playwright;
+
+public static class PlaywrightConstants
+{
+ public const int DefaultSessionInactivityTimeoutInMinutes = 30;
+
+ public static class Feature
+ {
+ public const string AdminWidget = "CrestApps.OrchardCore.AI.Playwright";
+ }
+
+ public const string ProtectorName = "PlaywrightProfilePassword";
+
+ public static class PromptIds
+ {
+ public const string Operator = "playwright-operator";
+ }
+
+ public static class CompletionContextKeys
+ {
+ public const string SessionMetadata = "PlaywrightSessionMetadata";
+ }
+
+ public static class ToolNames
+ {
+ public const string CaptureState = "playwright_capture_state";
+ public const string OpenAdminHome = "playwright_open_admin_home";
+ public const string OpenContentItems = "playwright_open_content_items";
+ public const string ListContentItems = "playwright_list_content_items";
+ public const string OpenContentItemEditor = "playwright_open_content_item_editor";
+ public const string OpenNewContentItem = "playwright_open_new_content_item";
+ public const string OpenEditorTab = "playwright_open_editor_tab";
+ public const string SetContentTitle = "playwright_set_content_title";
+ public const string SetFieldValue = "playwright_set_field_value";
+ public const string SetBodyField = "playwright_set_body_field";
+ public const string SaveDraft = "playwright_save_draft";
+ public const string PublishContent = "playwright_publish_content";
+ public const string PublishAndVerify = "playwright_publish_and_verify";
+ public const string InspectPageContent = "playwright_get_page_content";
+ public const string FindElement = "playwright_find_element";
+ public const string CheckElementExists = "playwright_check_element_exists";
+ public const string GetVisibleWidgets = "playwright_get_visible_widgets";
+ public const string TakeScreenshot = "playwright_take_screenshot";
+ public const string DiagnoseOrchardAction = "playwright_diagnose_orchard_action";
+ }
+
+ public static class ToolSets
+ {
+ public static readonly string[] Deterministic =
+ [
+ ToolNames.CaptureState,
+ ToolNames.OpenAdminHome,
+ ToolNames.OpenContentItems,
+ ToolNames.ListContentItems,
+ ToolNames.OpenContentItemEditor,
+ ToolNames.OpenNewContentItem,
+ ToolNames.OpenEditorTab,
+ ToolNames.SetContentTitle,
+ ToolNames.SetFieldValue,
+ ToolNames.SetBodyField,
+ ToolNames.SaveDraft,
+ ToolNames.PublishContent,
+ ToolNames.PublishAndVerify,
+ ToolNames.InspectPageContent,
+ ToolNames.FindElement,
+ ToolNames.CheckElementExists,
+ ToolNames.GetVisibleWidgets,
+ ToolNames.TakeScreenshot,
+ ToolNames.DiagnoseOrchardAction,
+ ];
+ }
+
+ public static string GetToolDescription(string toolName)
+ => toolName switch
+ {
+ ToolNames.CaptureState => "Captures the current browser state for deterministic Orchard admin planning.",
+ ToolNames.OpenAdminHome => "Ensures the Orchard admin shell is available for the current tenant.",
+ ToolNames.OpenContentItems => "Opens the Orchard content items list.",
+ ToolNames.ListContentItems => "Lists the visible Orchard content items from the current content items screen.",
+ ToolNames.OpenContentItemEditor => "Opens the editor for an existing Orchard content item by title, using the current list page before restarting navigation.",
+ ToolNames.OpenNewContentItem => "Starts the create-content flow for a requested Orchard content type.",
+ ToolNames.OpenEditorTab => "Opens an OrchardCore editor tab, section, or expander by name.",
+ ToolNames.SetContentTitle => "Sets the Title field on the current Orchard content editor.",
+ ToolNames.SetFieldValue => "Updates a labeled OrchardCore field using a typed field strategy.",
+ ToolNames.SetBodyField => "Updates a body-like OrchardCore field using rich-editor-aware append or replace behavior.",
+ ToolNames.SaveDraft => "Saves the current Orchard content item as a draft and captures the resulting page state.",
+ ToolNames.PublishContent => "Publishes the current Orchard content item and captures the resulting page state.",
+ ToolNames.PublishAndVerify => "Publishes the current Orchard content item and returns structured verification evidence.",
+ ToolNames.InspectPageContent => "Returns the visible content of the current page for grounded follow-up questions.",
+ ToolNames.FindElement => "Finds visible page elements that match a requested widget name, label, or text snippet.",
+ ToolNames.CheckElementExists => "Checks whether a requested control, widget, or text snippet is currently visible on the page.",
+ ToolNames.GetVisibleWidgets => "Lists visible widget-like cards, headings, and editor sections on the current page.",
+ ToolNames.TakeScreenshot => "Captures a screenshot of the current page and returns the saved file path.",
+ ToolNames.DiagnoseOrchardAction => "Attempts to find a named OrchardCore admin action using priority-ordered locator strategies. Captures full evidence (screenshots, HTML, URL, attempts) when the action is not found.",
+ _ => toolName,
+ };
+}
+
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Playwright/Services/IOrchardAdminPlaywrightService.cs b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Services/IOrchardAdminPlaywrightService.cs
new file mode 100644
index 000000000..f78b36065
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Services/IOrchardAdminPlaywrightService.cs
@@ -0,0 +1,51 @@
+using CrestApps.OrchardCore.AI.Playwright.Models;
+
+namespace CrestApps.OrchardCore.AI.Playwright.Services;
+
+public interface IOrchardAdminPlaywrightService
+{
+ Task CaptureStateAsync(IPlaywrightSession session, CancellationToken cancellationToken = default);
+
+ Task OpenAdminHomeAsync(IPlaywrightSession session, CancellationToken cancellationToken = default);
+
+ Task OpenContentItemsAsync(IPlaywrightSession session, CancellationToken cancellationToken = default);
+
+ Task ListVisibleContentItemsAsync(IPlaywrightSession session, int maxItems = 20, CancellationToken cancellationToken = default);
+
+ Task OpenNewContentItemAsync(IPlaywrightSession session, string contentType, CancellationToken cancellationToken = default);
+
+ Task OpenContentItemEditorAsync(IPlaywrightSession session, string title, CancellationToken cancellationToken = default);
+
+ Task OpenEditorTabAsync(
+ IPlaywrightSession session,
+ string tabName,
+ bool exact = false,
+ CancellationToken cancellationToken = default);
+
+ Task SetContentTitleAsync(IPlaywrightSession session, string title, CancellationToken cancellationToken = default);
+
+ Task SetFieldValueAsync(
+ IPlaywrightSession session,
+ string label,
+ string value,
+ string fieldType = "auto",
+ bool exact = false,
+ CancellationToken cancellationToken = default);
+
+ Task SetBodyFieldAsync(
+ IPlaywrightSession session,
+ string label,
+ string value,
+ string writeMode = "append",
+ bool exact = false,
+ CancellationToken cancellationToken = default);
+
+ Task SaveDraftAsync(IPlaywrightSession session, CancellationToken cancellationToken = default);
+
+ Task PublishContentAsync(IPlaywrightSession session, CancellationToken cancellationToken = default);
+
+ Task PublishAndVerifyAsync(
+ IPlaywrightSession session,
+ string expectedStatus = "Published",
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Playwright/Services/IOrchardEvidenceService.cs b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Services/IOrchardEvidenceService.cs
new file mode 100644
index 000000000..b433a907d
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Services/IOrchardEvidenceService.cs
@@ -0,0 +1,21 @@
+using CrestApps.OrchardCore.AI.Playwright.Models;
+
+namespace CrestApps.OrchardCore.AI.Playwright.Services;
+
+public interface IOrchardEvidenceService
+{
+ ///
+ /// Attempts to find a named OrchardCore admin action (e.g. Edit, Publish Now, Save Draft, Preview)
+ /// using multiple locator strategies in priority order.
+ ///
+ /// On success, returns = true with the
+ /// matched locator strategy name. No evidence files are written.
+ ///
+ ///
+ /// On failure, captures a full-page screenshot, a container screenshot when possible, and the
+ /// raw page HTML so the caller can diagnose whether the action is truly absent, hidden, renamed,
+ /// or blocked by an overlay.
+ ///
+ ///
+ Task FindOrchardElementWithEvidenceAsync(IPlaywrightSession session, string actionLabel, CancellationToken cancellationToken = default);
+}
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Playwright/Services/IPlaywrightActionVisualizer.cs b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Services/IPlaywrightActionVisualizer.cs
new file mode 100644
index 000000000..083dd14d3
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Services/IPlaywrightActionVisualizer.cs
@@ -0,0 +1,19 @@
+using Microsoft.Playwright;
+
+namespace CrestApps.OrchardCore.AI.Playwright.Services;
+
+public interface IPlaywrightActionVisualizer
+{
+ Task ShowLocatorActionAsync(
+ IPage page,
+ ILocator locator,
+ string action,
+ string target,
+ CancellationToken cancellationToken = default);
+
+ Task ShowPageActionAsync(
+ IPage page,
+ string action,
+ string target,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Playwright/Services/IPlaywrightObservationService.cs b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Services/IPlaywrightObservationService.cs
new file mode 100644
index 000000000..e0a64dc9e
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Services/IPlaywrightObservationService.cs
@@ -0,0 +1,8 @@
+using CrestApps.OrchardCore.AI.Playwright.Models;
+
+namespace CrestApps.OrchardCore.AI.Playwright.Services;
+
+public interface IPlaywrightObservationService
+{
+ Task CaptureAsync(IPlaywrightSession session, CancellationToken cancellationToken = default);
+}
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Playwright/Services/IPlaywrightPageInspectionService.cs b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Services/IPlaywrightPageInspectionService.cs
new file mode 100644
index 000000000..0d223d92b
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Services/IPlaywrightPageInspectionService.cs
@@ -0,0 +1,16 @@
+using CrestApps.OrchardCore.AI.Playwright.Models;
+
+namespace CrestApps.OrchardCore.AI.Playwright.Services;
+
+public interface IPlaywrightPageInspectionService
+{
+ Task GetPageContentAsync(IPlaywrightSession session, int maxLength, CancellationToken cancellationToken = default);
+
+ Task FindElementsAsync(IPlaywrightSession session, string query, int maxMatches, CancellationToken cancellationToken = default);
+
+ Task CheckElementExistsAsync(IPlaywrightSession session, string query, int maxMatches, CancellationToken cancellationToken = default);
+
+ Task GetVisibleWidgetsAsync(IPlaywrightSession session, int maxItems, CancellationToken cancellationToken = default);
+
+ Task TakeScreenshotAsync(IPlaywrightSession session, bool fullPage, CancellationToken cancellationToken = default);
+}
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Playwright/Services/IPlaywrightSession.cs b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Services/IPlaywrightSession.cs
new file mode 100644
index 000000000..201fa5382
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Services/IPlaywrightSession.cs
@@ -0,0 +1,67 @@
+using CrestApps.OrchardCore.AI.Playwright.Models;
+using Microsoft.Playwright;
+
+namespace CrestApps.OrchardCore.AI.Playwright.Services;
+
+///
+/// Represents an active Playwright browser session bound to a single chat session.
+///
+public interface IPlaywrightSession
+{
+ /// Gets the owning chat or interaction session identifier.
+ string SessionId { get; }
+
+ /// Gets the user or owner identifier associated with this Playwright session.
+ string OwnerId { get; }
+
+ /// Gets the current lifecycle status of this session.
+ PlaywrightSessionStatus Status { get; }
+
+ /// Gets the URL the browser is currently on, or null if not yet navigated.
+ string CurrentUrl { get; }
+
+ /// Gets the current page title, if known.
+ string CurrentPageTitle { get; }
+
+ /// Gets when this session was created.
+ DateTime CreatedAtUtc { get; }
+
+ /// Gets the last time a Playwright action touched this session.
+ DateTime LastActivityUtc { get; }
+
+ /// Gets the resolved public base URL for the current tenant.
+ string BaseUrl { get; }
+
+ /// Gets the resolved Orchard admin base URL.
+ string AdminBaseUrl { get; }
+
+ /// Gets the active browser mode used by the session.
+ PlaywrightBrowserMode BrowserMode { get; }
+
+ /// Gets whether the current page is considered authenticated for Orchard admin flows.
+ bool IsAuthenticated { get; }
+
+ /// Gets the most recent structured observation captured from the page.
+ PlaywrightObservation LastObservation { get; }
+
+ /// Gets the inactivity timeout for this session in minutes.
+ int SessionInactivityTimeoutInMinutes { get; }
+
+ /// Gets the active Playwright page.
+ IPage Page { get; }
+
+ ///
+ /// Returns a that is cancelled when either the session Stop
+ /// is requested or the session is closed.
+ ///
+ CancellationToken StopToken { get; }
+
+ /// Marks the session as running (tool call starting).
+ void MarkRunning();
+
+ /// Marks the session as idle (tool call finished).
+ void MarkIdle();
+
+ /// Cancels the current operation. Browser stays open.
+ void Stop();
+}
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Playwright/Services/IPlaywrightSessionManager.cs b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Services/IPlaywrightSessionManager.cs
new file mode 100644
index 000000000..798b3d014
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Services/IPlaywrightSessionManager.cs
@@ -0,0 +1,49 @@
+using CrestApps.OrchardCore.AI.Playwright.Models;
+
+namespace CrestApps.OrchardCore.AI.Playwright.Services;
+
+///
+/// Manages Playwright browser sessions keyed by chat session ID.
+/// Implementations are registered as singletons.
+///
+public interface IPlaywrightSessionManager
+{
+ ///
+ /// Returns the existing session for the request, or creates one using the supplied
+ /// browser mode, base URLs, and optional credentials.
+ ///
+ Task GetOrCreateAsync(
+ PlaywrightSessionRequest request,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Returns the session for , or if none exists.
+ ///
+ IPlaywrightSession GetSession(string chatSessionId, string ownerId = null);
+
+ ///
+ /// Navigates to Orchard admin and attempts authentication if the current page is at the login screen
+ /// and the session has credentials available.
+ ///
+ Task EnsureAdminReadyAsync(IPlaywrightSession session, CancellationToken cancellationToken = default);
+
+ ///
+ /// Cancels the current in-flight operation for the session. Browser stays open.
+ ///
+ void Stop(string chatSessionId, string ownerId = null);
+
+ ///
+ /// Disposes the session and closes the browser window.
+ ///
+ Task CloseAsync(string chatSessionId, string ownerId = null);
+
+ ///
+ /// Closes any Playwright sessions that have been idle past their configured timeout.
+ ///
+ Task CloseInactiveSessionsAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Returns all active (non-closed) sessions keyed by chat session ID.
+ ///
+ IReadOnlyDictionary GetActiveSessions(string ownerId = null);
+}
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Playwright/Services/IPlaywrightSessionRequestResolver.cs b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Services/IPlaywrightSessionRequestResolver.cs
new file mode 100644
index 000000000..bb28b1cd6
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Services/IPlaywrightSessionRequestResolver.cs
@@ -0,0 +1,8 @@
+using CrestApps.OrchardCore.AI.Playwright.Models;
+
+namespace CrestApps.OrchardCore.AI.Playwright.Services;
+
+public interface IPlaywrightSessionRequestResolver
+{
+ PlaywrightSessionRequest Resolve(object resource, string chatSessionId);
+}
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Playwright/Services/OrchardAdminPlaywrightService.cs b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Services/OrchardAdminPlaywrightService.cs
new file mode 100644
index 000000000..932e43a5b
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Services/OrchardAdminPlaywrightService.cs
@@ -0,0 +1,1791 @@
+using System.Text.RegularExpressions;
+using CrestApps.OrchardCore.AI.Playwright.Models;
+using Microsoft.Extensions.Logging;
+using Microsoft.Playwright;
+
+namespace CrestApps.OrchardCore.AI.Playwright.Services;
+
+public sealed class OrchardAdminPlaywrightService : IOrchardAdminPlaywrightService
+{
+ private const int EditorSubmitTimeoutMs = 60_000;
+ private static readonly Regex RowScopedActionPattern = new(
+ @"^(?Edit|View|Preview|Display|Delete|Clone|Duplicate|Publish|Unpublish|Draft|Save Draft)\s+(?.+)$",
+ RegexOptions.Compiled | RegexOptions.IgnoreCase);
+ private static readonly string[] ContentItemContainerSelectors =
+ [
+ "tbody tr",
+ "table tr",
+ "tr",
+ "[data-content-item-id]",
+ ".content-item",
+ ".list-group-item",
+ "article",
+ "li",
+ ];
+ private static readonly string[] RowActionMenuNames =
+ [
+ "Actions",
+ "More actions",
+ "More",
+ "Options",
+ ];
+
+ private readonly IPlaywrightSessionManager _sessionManager;
+ private readonly IPlaywrightObservationService _observationService;
+ private readonly IPlaywrightActionVisualizer _actionVisualizer;
+ private readonly ILogger _logger;
+
+ public OrchardAdminPlaywrightService(
+ IPlaywrightSessionManager sessionManager,
+ IPlaywrightObservationService observationService,
+ IPlaywrightActionVisualizer actionVisualizer,
+ ILogger logger)
+ {
+ _sessionManager = sessionManager;
+ _observationService = observationService;
+ _actionVisualizer = actionVisualizer;
+ _logger = logger;
+ }
+
+ public Task CaptureStateAsync(IPlaywrightSession session, CancellationToken cancellationToken = default)
+ => _observationService.CaptureAsync(session, cancellationToken);
+
+ public async Task OpenAdminHomeAsync(IPlaywrightSession session, CancellationToken cancellationToken = default)
+ {
+ await _sessionManager.EnsureAdminReadyAsync(session, cancellationToken);
+ return await _observationService.CaptureAsync(session, cancellationToken);
+ }
+
+ public async Task OpenContentItemsAsync(IPlaywrightSession session, CancellationToken cancellationToken = default)
+ {
+ await _sessionManager.EnsureAdminReadyAsync(session, cancellationToken);
+ var page = await GetActivePageAsync(session, cancellationToken);
+
+ var targetUrl = PlaywrightSessionRequestResolver.CombineUrl(session.AdminBaseUrl, "Contents/ContentItems");
+ await page.GotoAsync(targetUrl, new PageGotoOptions
+ {
+ WaitUntil = WaitUntilState.DOMContentLoaded,
+ Timeout = 30_000,
+ }).WaitAsync(cancellationToken);
+
+ var observation = await _observationService.CaptureAsync(session, cancellationToken);
+ if (observation.IsLoginPage || !observation.IsAuthenticated)
+ {
+ return observation;
+ }
+
+ if (!observation.IsLoginPage && !IsContentItemsPage(observation))
+ {
+ await TryClickNamedElementAsync(page, "Content Items", cancellationToken);
+ observation = await _observationService.CaptureAsync(session, cancellationToken);
+ }
+
+ return observation;
+ }
+
+ public async Task ListVisibleContentItemsAsync(
+ IPlaywrightSession session,
+ int maxItems = 20,
+ CancellationToken cancellationToken = default)
+ {
+ var observation = await EnsureContentItemsPageAsync(session, cancellationToken);
+ var page = await GetActivePageAsync(session, cancellationToken);
+
+ var items = observation.IsLoginPage || !observation.IsAuthenticated
+ ? Array.Empty()
+ : await GetVisibleContentItemsAsync(page, maxItems, cancellationToken);
+
+ return new PlaywrightContentListResult
+ {
+ Url = page.Url,
+ Title = await page.TitleAsync().WaitAsync(cancellationToken),
+ MainHeading = observation.MainHeading,
+ ItemCount = items.Count,
+ Items = items,
+ };
+ }
+
+ public async Task OpenNewContentItemAsync(IPlaywrightSession session, string contentType, CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(contentType);
+
+ var observation = await OpenContentItemsAsync(session, cancellationToken);
+ if (observation.IsLoginPage || !observation.IsAuthenticated)
+ {
+ return observation;
+ }
+
+ var page = await GetActivePageAsync(session, cancellationToken);
+ await TryClickAnyAsync(page, [("button", "New"), ("link", "New"), ("button", "Create"), ("link", "Create")], cancellationToken);
+
+ var contentTypeLocator = await ResolveContentTypeLocatorAsync(page, contentType, cancellationToken);
+ await ClickWithoutNavigationWaitAsync(page, contentTypeLocator, $"selecting {contentType}", cancellationToken);
+ await WaitForEditorAsync(page, cancellationToken);
+
+ return await _observationService.CaptureAsync(session, cancellationToken);
+ }
+
+ public async Task OpenContentItemEditorAsync(
+ IPlaywrightSession session,
+ string title,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(title);
+
+ var observation = await EnsureContentItemsPageAsync(session, cancellationToken);
+ var page = await GetActivePageAsync(session, cancellationToken);
+
+ if (observation.IsLoginPage || !observation.IsAuthenticated)
+ {
+ return new PlaywrightContentItemOpenResult
+ {
+ RequestedTitle = title,
+ Observation = observation,
+ };
+ }
+
+ var visibleItems = await GetVisibleContentItemsAsync(page, 25, cancellationToken);
+ var selection = SelectBestContentItem(visibleItems, title);
+ var usedSearch = false;
+
+ if (selection is null && await TrySearchContentItemsAsync(page, title, cancellationToken))
+ {
+ usedSearch = true;
+ visibleItems = await GetVisibleContentItemsAsync(page, 25, cancellationToken);
+ selection = SelectBestContentItem(visibleItems, title);
+ }
+
+ if (selection is null)
+ {
+ return new PlaywrightContentItemOpenResult
+ {
+ RequestedTitle = title,
+ UsedSearch = usedSearch,
+ ClosestTitles = GetClosestTitles(visibleItems, title),
+ Observation = await _observationService.CaptureAsync(session, cancellationToken),
+ };
+ }
+
+ if (_logger.IsEnabled(LogLevel.Debug))
+ {
+ _logger.LogDebug(
+ "Opening content item editor for requested title {RequestedTitle} using match {MatchedTitle} ({MatchMode}).",
+ title,
+ selection.Item.Title,
+ selection.MatchMode);
+ }
+
+ if (!await TryClickContentItemRowActionAsync(page, "link", $"Edit {selection.Item.Title}", cancellationToken))
+ {
+ return new PlaywrightContentItemOpenResult
+ {
+ RequestedTitle = title,
+ MatchedTitle = selection.Item.Title,
+ MatchMode = selection.MatchMode,
+ UsedSearch = usedSearch,
+ ClosestTitles = GetClosestTitles(visibleItems, title),
+ Observation = await _observationService.CaptureAsync(session, cancellationToken),
+ };
+ }
+
+ await WaitForEditorAsync(page, cancellationToken);
+ observation = await _observationService.CaptureAsync(session, cancellationToken);
+
+ return new PlaywrightContentItemOpenResult
+ {
+ RequestedTitle = title,
+ MatchedTitle = selection.Item.Title,
+ MatchMode = selection.MatchMode,
+ UsedSearch = usedSearch,
+ ClosestTitles = GetClosestTitles(visibleItems, title),
+ Observation = observation,
+ };
+ }
+
+ public async Task SetContentTitleAsync(IPlaywrightSession session, string title, CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(title);
+
+ var page = await GetActivePageAsync(session, cancellationToken);
+ var titleField = page.GetByLabel("Title", new() { Exact = true }).First;
+
+ if (await titleField.CountAsync().WaitAsync(cancellationToken) == 0)
+ {
+ titleField = page.Locator("input[name='TitlePart.Title'], input[id*='TitlePart_Title'], input[name='Title']").First;
+ }
+
+ await ShowFillIndicatorAsync(page, titleField, "Title", cancellationToken);
+ await titleField.FillAsync(title, new LocatorFillOptions { Timeout = 10_000 }).WaitAsync(cancellationToken);
+ await titleField.PressAsync("Tab").WaitAsync(cancellationToken);
+
+ return await _observationService.CaptureAsync(session, cancellationToken);
+ }
+
+ public Task SaveDraftAsync(IPlaywrightSession session, CancellationToken cancellationToken = default)
+ => ClickEditorActionAsync(session, ["Save Draft", "Save"], cancellationToken);
+
+ public Task PublishContentAsync(IPlaywrightSession session, CancellationToken cancellationToken = default)
+ => ClickEditorActionAsync(session, ["Publish", "Publish Draft"], cancellationToken);
+
+ public async Task OpenEditorTabAsync(
+ IPlaywrightSession session,
+ string tabName,
+ bool exact = false,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(tabName);
+
+ var page = await GetActivePageAsync(session, cancellationToken);
+ var target = await ResolveEditorTargetAsync(page, tabName, exact, cancellationToken);
+ if (target is null)
+ {
+ throw new TimeoutException($"Unable to locate an Orchard editor tab or section named '{tabName}'.");
+ }
+
+ if (!await IsEditorTargetExpandedAsync(target.Locator, cancellationToken))
+ {
+ await ClickWithoutNavigationWaitAsync(page, target.Locator, $"opening {target.MatchedText}", cancellationToken);
+ }
+
+ var observation = await CaptureAfterInteractionAsync(session, page, cancellationToken);
+
+ return new PlaywrightEditorTargetResult
+ {
+ RequestedTarget = tabName,
+ MatchedTarget = target.MatchedText,
+ TargetKind = target.TargetKind,
+ Observation = observation,
+ };
+ }
+
+ public async Task SetFieldValueAsync(
+ IPlaywrightSession session,
+ string label,
+ string value,
+ string fieldType = "auto",
+ bool exact = false,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(label);
+
+ var normalizedFieldType = NormalizeFieldType(fieldType);
+ var page = await GetActivePageAsync(session, cancellationToken);
+ var locator = await ResolveFieldLocatorAsync(page, label, exact, cancellationToken);
+ await ShowFillIndicatorAsync(page, locator, label, cancellationToken);
+
+ var resolvedFieldType = await TrySetFieldValueAsync(locator, label, value ?? string.Empty, normalizedFieldType, append: false, cancellationToken);
+ if (string.IsNullOrWhiteSpace(resolvedFieldType))
+ {
+ throw new TimeoutException($"Unable to set the Orchard field '{label}' as '{normalizedFieldType}'.");
+ }
+
+ return new PlaywrightFieldEditResult
+ {
+ Label = label,
+ RequestedFieldType = normalizedFieldType,
+ RequestedEditMode = "replace",
+ ResolvedFieldType = resolvedFieldType,
+ Observation = await _observationService.CaptureAsync(session, cancellationToken),
+ };
+ }
+
+ public async Task SetBodyFieldAsync(
+ IPlaywrightSession session,
+ string label,
+ string value,
+ string mode = "append",
+ bool exact = false,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(label);
+
+ var normalizedMode = NormalizeBodyEditMode(mode);
+ var page = await GetActivePageAsync(session, cancellationToken);
+ var locator = await ResolveFieldLocatorAsync(page, label, exact, cancellationToken);
+ await ShowFillIndicatorAsync(page, locator, label, cancellationToken);
+
+ var resolvedFieldType = await TrySetFieldValueAsync(
+ locator,
+ label,
+ value ?? string.Empty,
+ requestedFieldType: "richtext",
+ append: normalizedMode.Equals("append", StringComparison.Ordinal),
+ cancellationToken);
+
+ if (string.IsNullOrWhiteSpace(resolvedFieldType))
+ {
+ throw new TimeoutException($"Unable to edit the Orchard body field '{label}'.");
+ }
+
+ return new PlaywrightFieldEditResult
+ {
+ Label = label,
+ RequestedFieldType = "body",
+ RequestedEditMode = normalizedMode,
+ ResolvedFieldType = resolvedFieldType,
+ Observation = await _observationService.CaptureAsync(session, cancellationToken),
+ };
+ }
+
+ public async Task PublishAndVerifyAsync(
+ IPlaywrightSession session,
+ string expectedStatus = "Published",
+ CancellationToken cancellationToken = default)
+ {
+ var observation = await PublishContentAsync(session, cancellationToken);
+ var verificationSignals = BuildPublishVerificationSignals(observation, expectedStatus);
+
+ return new PlaywrightPublishVerificationResult
+ {
+ Action = "publish",
+ ExpectedStatus = string.IsNullOrWhiteSpace(expectedStatus) ? "Published" : expectedStatus.Trim(),
+ Verified = verificationSignals.Count > 0,
+ VerificationSignals = verificationSignals,
+ Observation = observation,
+ };
+ }
+
+ private async Task ClickEditorActionAsync(
+ IPlaywrightSession session,
+ IReadOnlyList candidateNames,
+ CancellationToken cancellationToken)
+ {
+ var observation = await _observationService.CaptureAsync(session, cancellationToken);
+ if (observation.IsLoginPage || !observation.IsAuthenticated)
+ {
+ return observation;
+ }
+
+ var page = await GetActivePageAsync(session, cancellationToken);
+ try
+ {
+ await TryClickEditorActionAsync(page, candidateNames, cancellationToken);
+ }
+ catch (TimeoutException ex)
+ {
+ var stalledObservation = await CaptureAfterEditorActionFailureAsync(session, page, cancellationToken);
+ throw new TimeoutException(BuildEditorActionFailureMessage(candidateNames, stalledObservation), ex);
+ }
+
+ return await CaptureAfterEditorActionAsync(session, page, cancellationToken);
+ }
+
+ private async Task TryClickContentItemRowActionAsync(
+ IPage page,
+ string role,
+ string name,
+ CancellationToken cancellationToken)
+ {
+ if (!role.Equals("link", StringComparison.OrdinalIgnoreCase)
+ && !role.Equals("button", StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ var match = RowScopedActionPattern.Match(name);
+ if (!match.Success)
+ {
+ return false;
+ }
+
+ var actionName = match.Groups["action"].Value.Trim();
+ var contentItemTitle = match.Groups["title"].Value.Trim();
+ var contentRow = await FindContentItemContainerAsync(page, contentItemTitle, cancellationToken);
+
+ if (contentRow is null)
+ {
+ if (_logger.IsEnabled(LogLevel.Debug))
+ {
+ _logger.LogDebug("Could not find a content row for {ContentItemTitle} while resolving {ActionName}.", contentItemTitle, actionName);
+ }
+ return false;
+ }
+
+ if (_logger.IsEnabled(LogLevel.Debug))
+ {
+ _logger.LogDebug("Trying row-scoped action {ActionName} for content item {ContentItemTitle}.", actionName, contentItemTitle);
+ }
+
+ if (await TryClickActionWithinContainerAsync(page, contentRow, actionName, contentItemTitle, cancellationToken))
+ {
+ return true;
+ }
+
+ foreach (var menuName in RowActionMenuNames)
+ {
+ if (!await TryClickNamedElementAsync(page, contentRow, menuName, false, cancellationToken))
+ {
+ continue;
+ }
+
+ if (await TryClickAnyAsync(page, [("button", actionName), ("link", actionName)], false, false, cancellationToken))
+ {
+ return true;
+ }
+ }
+
+ if (_logger.IsEnabled(LogLevel.Debug))
+ {
+ _logger.LogDebug(
+ "Found the content row for {ContentItemTitle} but no visible {ActionName} action inside it.",
+ contentItemTitle,
+ actionName);
+ }
+
+ return false;
+ }
+
+ private async Task EnsureContentItemsPageAsync(
+ IPlaywrightSession session,
+ CancellationToken cancellationToken)
+ {
+ var observation = await _observationService.CaptureAsync(session, cancellationToken);
+ if (observation.IsLoginPage || !observation.IsAuthenticated)
+ {
+ return observation;
+ }
+
+ if (IsContentItemsPage(observation))
+ {
+ return observation;
+ }
+
+ if (_logger.IsEnabled(LogLevel.Debug))
+ {
+ _logger.LogDebug("Current page is not the Orchard content items list. Opening it for the active Playwright session.");
+ }
+
+ return await OpenContentItemsAsync(session, cancellationToken);
+ }
+
+ private static async Task GetActivePageAsync(IPlaywrightSession session, CancellationToken cancellationToken)
+ {
+ return session switch
+ {
+ PlaywrightSession concreteSession => await concreteSession.GetOrCreatePageAsync(cancellationToken),
+ _ => session.Page,
+ };
+ }
+
+ private static bool IsContentItemsPage(PlaywrightObservation observation)
+ {
+ if (observation is null)
+ {
+ return false;
+ }
+
+ return observation.CurrentUrl?.Contains("/Contents/ContentItems", StringComparison.OrdinalIgnoreCase) == true
+ || observation.MainHeading?.Contains("Content", StringComparison.OrdinalIgnoreCase) == true;
+ }
+
+ private static ContentItemSelection SelectBestContentItem(
+ IReadOnlyList items,
+ string requestedTitle)
+ {
+ ContentItemSelection best = null;
+
+ foreach (var item in items)
+ {
+ var match = GetTitleMatch(item.Title, requestedTitle);
+ if (match.Score <= 0)
+ {
+ continue;
+ }
+
+ if (best is null || match.Score > best.Score)
+ {
+ best = new ContentItemSelection
+ {
+ Item = item,
+ MatchMode = match.Mode,
+ Score = match.Score,
+ };
+ }
+ }
+
+ return best is { Score: >= 550 } ? best : null;
+ }
+
+ private static IReadOnlyList GetClosestTitles(
+ IReadOnlyList items,
+ string requestedTitle)
+ {
+ return items
+ .Select(item => new
+ {
+ item.Title,
+ Match = GetTitleMatch(item.Title, requestedTitle),
+ })
+ .Where(entry => entry.Match.Score > 0)
+ .OrderByDescending(entry => entry.Match.Score)
+ .Select(entry => entry.Title)
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .Take(5)
+ .ToList();
+ }
+
+ private static ILocator GetLocatorByRole(IPage page, string role, string name, bool exact)
+ {
+ return role.ToLowerInvariant() switch
+ {
+ "button" => page.GetByRole(AriaRole.Button, new() { Name = name, Exact = exact }).First,
+ "link" => page.GetByRole(AriaRole.Link, new() { Name = name, Exact = exact }).First,
+ "textbox" => page.GetByRole(AriaRole.Textbox, new() { Name = name, Exact = exact }).First,
+ "menuitem" => page.GetByRole(AriaRole.Menuitem, new() { Name = name, Exact = exact }).First,
+ "tab" => page.GetByRole(AriaRole.Tab, new() { Name = name, Exact = exact }).First,
+ _ => throw new InvalidOperationException($"Unsupported role '{role}'."),
+ };
+ }
+
+ private static async Task ResolveFieldLocatorAsync(
+ IPage page,
+ string label,
+ bool exact,
+ CancellationToken cancellationToken)
+ {
+ var labeledLocator = page.GetByLabel(label, new() { Exact = exact }).First;
+ if (await labeledLocator.CountAsync().WaitAsync(cancellationToken) > 0)
+ {
+ return labeledLocator;
+ }
+
+ var selectorValue = Regex.Replace(label, @"\s+", string.Empty);
+ var fallbackSelectors = new[]
+ {
+ $"textarea[id*='{selectorValue}' i], textarea[name*='{selectorValue}' i]",
+ $"input[id*='{selectorValue}' i], input[name*='{selectorValue}' i]",
+ $"select[id*='{selectorValue}' i], select[name*='{selectorValue}' i]",
+ $"[contenteditable='true'][aria-label*='{label}' i], iframe[title*='{label}' i]",
+ };
+
+ foreach (var selector in fallbackSelectors)
+ {
+ var locator = page.Locator(selector).First;
+ if (await locator.CountAsync().WaitAsync(cancellationToken) > 0)
+ {
+ return locator;
+ }
+ }
+
+ throw new TimeoutException($"Unable to locate a field labeled '{label}'.");
+ }
+
+ private static async Task ResolveContentTypeLocatorAsync(
+ IPage page,
+ string contentType,
+ CancellationToken cancellationToken)
+ {
+ var exactCandidates = new ILocator[]
+ {
+ page.GetByRole(AriaRole.Link, new() { Name = contentType, Exact = true }).First,
+ page.GetByRole(AriaRole.Button, new() { Name = contentType, Exact = true }).First,
+ page.GetByText(contentType, new PageGetByTextOptions { Exact = true }).First,
+ };
+
+ foreach (var candidate in exactCandidates)
+ {
+ if (await candidate.CountAsync().WaitAsync(cancellationToken) > 0)
+ {
+ return candidate;
+ }
+ }
+
+ var relaxedCandidates = new ILocator[]
+ {
+ page.GetByRole(AriaRole.Link, new() { Name = contentType, Exact = false }).First,
+ page.GetByRole(AriaRole.Button, new() { Name = contentType, Exact = false }).First,
+ page.GetByText(contentType, new PageGetByTextOptions { Exact = false }).First,
+ };
+
+ foreach (var candidate in relaxedCandidates)
+ {
+ if (await candidate.CountAsync().WaitAsync(cancellationToken) > 0)
+ {
+ return candidate;
+ }
+ }
+
+ return page.GetByText(contentType, new PageGetByTextOptions { Exact = false }).First;
+ }
+
+ private static string NormalizeFieldType(string fieldType)
+ => (fieldType ?? "auto").Trim().ToLowerInvariant() switch
+ {
+ "" or "auto" => "auto",
+ "text" => "text",
+ "textarea" => "textarea",
+ "select" or "dropdown" => "select",
+ "checkbox" or "bool" or "boolean" => "checkbox",
+ "radio" => "radio",
+ "richtext" or "html" or "wysiwyg" => "richtext",
+ var unsupported => throw new InvalidOperationException($"Unsupported Orchard field type '{unsupported}'."),
+ };
+
+ private static string NormalizeBodyEditMode(string mode)
+ => (mode ?? "append").Trim().ToLowerInvariant() switch
+ {
+ "" or "append" => "append",
+ "replace" => "replace",
+ var unsupported => throw new InvalidOperationException($"Unsupported body edit mode '{unsupported}'. Use 'append' or 'replace'."),
+ };
+
+ private static IReadOnlyList BuildPublishVerificationSignals(PlaywrightObservation observation, string expectedStatus)
+ {
+ var normalizedStatus = string.IsNullOrWhiteSpace(expectedStatus)
+ ? "published"
+ : expectedStatus.Trim().ToLowerInvariant();
+
+ var signals = new List();
+ if (observation is null)
+ {
+ return signals;
+ }
+
+ if (!string.IsNullOrWhiteSpace(observation.ToastMessage)
+ && observation.ToastMessage.Contains(normalizedStatus, StringComparison.OrdinalIgnoreCase))
+ {
+ signals.Add($"Toast confirmed {expectedStatus}.");
+ }
+
+ if (observation.VisibleButtons.Any(button => button.Contains("Unpublish", StringComparison.OrdinalIgnoreCase)))
+ {
+ signals.Add("Editor actions now include Unpublish.");
+ }
+
+ if (observation.VisibleButtons.Any(button => button.Contains("View", StringComparison.OrdinalIgnoreCase)
+ || button.Contains("Preview", StringComparison.OrdinalIgnoreCase)))
+ {
+ signals.Add("Editor actions now include view or preview.");
+ }
+
+ if (observation.ValidationMessages.Count > 0)
+ {
+ return [];
+ }
+
+ return signals
+ .Distinct(StringComparer.OrdinalIgnoreCase)
+ .ToList();
+ }
+
+ private static async Task ResolveEditorTargetAsync(
+ IPage page,
+ string tabName,
+ bool exact,
+ CancellationToken cancellationToken)
+ {
+ foreach (var currentExact in exact ? new[] { true } : new[] { true, false })
+ {
+ foreach (var (kind, locator) in new[]
+ {
+ ("tab", page.GetByRole(AriaRole.Tab, new() { Name = tabName, Exact = currentExact }).First),
+ ("tab", page.Locator($".nav-tabs a:has-text(\"{EscapeHasTextSelector(tabName)}\"), .nav-tabs button:has-text(\"{EscapeHasTextSelector(tabName)}\"), .nav-pills a:has-text(\"{EscapeHasTextSelector(tabName)}\"), .nav-pills button:has-text(\"{EscapeHasTextSelector(tabName)}\"), [data-bs-toggle='tab']:has-text(\"{EscapeHasTextSelector(tabName)}\")").First),
+ ("accordion", page.Locator($".accordion-button:has-text(\"{EscapeHasTextSelector(tabName)}\"), [data-bs-toggle='collapse']:has-text(\"{EscapeHasTextSelector(tabName)}\")").First),
+ ("summary", page.Locator($"details > summary:has-text(\"{EscapeHasTextSelector(tabName)}\"), summary:has-text(\"{EscapeHasTextSelector(tabName)}\")").First),
+ ("section", page.Locator($".card-header button:has-text(\"{EscapeHasTextSelector(tabName)}\"), .card-header a:has-text(\"{EscapeHasTextSelector(tabName)}\"), fieldset > legend:has-text(\"{EscapeHasTextSelector(tabName)}\")").First),
+ })
+ {
+ if (await locator.CountAsync().WaitAsync(cancellationToken) == 0
+ || !await locator.IsVisibleAsync().WaitAsync(cancellationToken))
+ {
+ continue;
+ }
+
+ var matchedText = NormalizeTitle(await locator.InnerTextAsync().WaitAsync(cancellationToken))
+ ?? tabName;
+
+ if (currentExact
+ && !matchedText.Equals(tabName, StringComparison.OrdinalIgnoreCase)
+ && !matchedText.Contains(tabName, StringComparison.OrdinalIgnoreCase))
+ {
+ continue;
+ }
+
+ return new EditorTargetMatch
+ {
+ Locator = locator,
+ MatchedText = matchedText,
+ TargetKind = kind,
+ };
+ }
+ }
+
+ return null;
+ }
+
+ private static async Task IsEditorTargetExpandedAsync(ILocator locator, CancellationToken cancellationToken)
+ {
+ if (await locator.CountAsync().WaitAsync(cancellationToken) == 0)
+ {
+ return false;
+ }
+
+ return await locator.EvaluateAsync(
+ """
+ element => {
+ const ariaSelected = (element.getAttribute("aria-selected") || "").toLowerCase();
+ const ariaExpanded = (element.getAttribute("aria-expanded") || "").toLowerCase();
+ const className = (element.className || "").toString().toLowerCase();
+
+ if (ariaSelected === "true" || ariaExpanded === "true") {
+ return true;
+ }
+
+ if (className.includes("active") || className.includes("show")) {
+ return true;
+ }
+
+ const details = element.closest("details");
+ if (details?.open) {
+ return true;
+ }
+
+ const controls = element.getAttribute("aria-controls");
+ if (controls) {
+ const target = document.getElementById(controls);
+ if (target) {
+ const targetClass = (target.className || "").toString().toLowerCase();
+ const style = window.getComputedStyle(target);
+ if (targetClass.includes("show") || style.display !== "none") {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+ """).WaitAsync(cancellationToken);
+ }
+
+ private static string EscapeHasTextSelector(string value)
+ => (value ?? string.Empty)
+ .Replace("\\", "\\\\", StringComparison.Ordinal)
+ .Replace("\"", "\\\"", StringComparison.Ordinal);
+
+ private async Task TryClickNamedElementAsync(
+ IPage page,
+ ILocator scope,
+ string name,
+ bool throwOnFailure,
+ CancellationToken cancellationToken)
+ {
+ foreach (var locator in new[]
+ {
+ scope.GetByRole(AriaRole.Link, new() { Name = name, Exact = true }).First,
+ scope.GetByRole(AriaRole.Button, new() { Name = name, Exact = true }).First,
+ scope.GetByRole(AriaRole.Link, new() { Name = name, Exact = false }).First,
+ scope.GetByRole(AriaRole.Button, new() { Name = name, Exact = false }).First,
+ scope.GetByText(name, new LocatorGetByTextOptions { Exact = true }).First,
+ scope.GetByText(name, new LocatorGetByTextOptions { Exact = false }).First,
+ })
+ {
+ if (await locator.CountAsync().WaitAsync(cancellationToken) == 0)
+ {
+ continue;
+ }
+
+ if (!await locator.IsVisibleAsync().WaitAsync(cancellationToken))
+ {
+ continue;
+ }
+
+ await ClickWithoutNavigationWaitAsync(page, locator, $"clicking {name}", cancellationToken);
+
+ return true;
+ }
+
+ if (throwOnFailure)
+ {
+ throw new TimeoutException($"Unable to click '{name}'.");
+ }
+
+ return false;
+ }
+
+ private async Task TryClickNamedElementAsync(IPage page, string name, CancellationToken cancellationToken)
+ {
+ await TryClickAnyAsync(page, [("link", name), ("button", name)], cancellationToken);
+ }
+
+ private async Task TrySearchContentItemsAsync(
+ IPage page,
+ string searchText,
+ CancellationToken cancellationToken)
+ {
+ foreach (var candidate in new[]
+ {
+ page.GetByLabel("Search", new() { Exact = false }).First,
+ page.GetByPlaceholder("Search", new() { Exact = false }).First,
+ page.Locator("input[type='search'], input[name*='search' i], input[name='q'], input[placeholder*='search' i]").First,
+ })
+ {
+ if (await candidate.CountAsync().WaitAsync(cancellationToken) == 0
+ || !await candidate.IsVisibleAsync().WaitAsync(cancellationToken))
+ {
+ continue;
+ }
+
+ if (_logger.IsEnabled(LogLevel.Debug))
+ {
+ _logger.LogDebug("Searching Orchard content items for {SearchText}.", searchText);
+ }
+
+ await candidate.FillAsync(searchText, new LocatorFillOptions
+ {
+ Timeout = 10_000,
+ }).WaitAsync(cancellationToken);
+
+ if (!await TryClickAnyAsync(page, [("button", "Search"), ("button", "Filter"), ("link", "Search")], false, false, cancellationToken))
+ {
+ await candidate.PressAsync("Enter").WaitAsync(cancellationToken);
+ }
+
+ await CaptureAfterInteractionAsyncFromPageAsync(page, cancellationToken);
+ return true;
+ }
+
+ return false;
+ }
+
+ private async Task TryClickAnyAsync(
+ IPage page,
+ IReadOnlyList<(string role, string name)> candidates,
+ CancellationToken cancellationToken)
+ {
+ if (page.IsClosed)
+ {
+ throw new InvalidOperationException("The Playwright browser page is no longer available.");
+ }
+
+ foreach (var candidate in candidates)
+ {
+ var locator = GetLocatorByRole(page, candidate.role, candidate.name, false);
+
+ if (await locator.CountAsync().WaitAsync(cancellationToken) == 0)
+ {
+ continue;
+ }
+
+ if (await locator.IsVisibleAsync().WaitAsync(cancellationToken))
+ {
+ await ClickWithoutNavigationWaitAsync(page, locator, $"clicking {candidate.name}", cancellationToken);
+ return;
+ }
+ }
+
+ var fallback = candidates[0];
+ var textLocator = page.GetByText(fallback.name, new PageGetByTextOptions { Exact = true }).First;
+ await ClickWithoutNavigationWaitAsync(page, textLocator, $"clicking {fallback.name}", cancellationToken);
+ }
+
+ private async Task TryClickEditorActionAsync(
+ IPage page,
+ IReadOnlyList candidateNames,
+ CancellationToken cancellationToken)
+ {
+ if (page.IsClosed)
+ {
+ throw new InvalidOperationException("The Playwright browser page is no longer available.");
+ }
+
+ foreach (var name in candidateNames)
+ {
+ if (await TryClickAnyAsync(page, [("button", name), ("link", name)], false, true, cancellationToken))
+ {
+ return;
+ }
+ }
+
+ foreach (var name in candidateNames)
+ {
+ var submitLocator = page.Locator(
+ $"button[name*='{name}' i], input[type='submit'][value*='{name}' i], button[title*='{name}' i], [aria-label*='{name}' i]").First;
+
+ if (await submitLocator.CountAsync().WaitAsync(cancellationToken) > 0
+ && await submitLocator.IsVisibleAsync().WaitAsync(cancellationToken))
+ {
+ await ClickWithoutNavigationWaitAsync(page, submitLocator, $"clicking {name}", cancellationToken);
+ return;
+ }
+ }
+
+ var visibleButtons = await page.EvaluateAsync(
+ @"() => Array.from(document.querySelectorAll('button, input[type=""submit""], a.btn'))
+ .filter(element => element.offsetParent !== null)
+ .map(element => (element.innerText || element.value || element.getAttribute('aria-label') || '').trim())
+ .filter(Boolean)
+ .slice(0, 12)").WaitAsync(cancellationToken);
+
+ throw new TimeoutException(
+ $"No matching editor action was found. Tried: {string.Join(", ", candidateNames)}. Visible actions: {string.Join(", ", visibleButtons ?? [])}");
+ }
+
+ private async Task TryClickAnyAsync(
+ IPage page,
+ IReadOnlyList<(string role, string name)> candidates,
+ bool throwOnFailure,
+ bool treatAsSubmit,
+ CancellationToken cancellationToken)
+ {
+ if (page.IsClosed)
+ {
+ throw new InvalidOperationException("The Playwright browser page is no longer available.");
+ }
+
+ foreach (var candidate in candidates)
+ {
+ var locator = GetLocatorByRole(page, candidate.role, candidate.name, false);
+
+ if (await locator.CountAsync().WaitAsync(cancellationToken) == 0)
+ {
+ continue;
+ }
+
+ if (await locator.IsVisibleAsync().WaitAsync(cancellationToken))
+ {
+ await ClickWithoutNavigationWaitAsync(
+ page,
+ locator,
+ treatAsSubmit ? $"submitting {candidate.name}" : $"clicking {candidate.name}",
+ cancellationToken);
+ return true;
+ }
+ }
+
+ var fallback = candidates[0];
+ var textLocator = page.GetByText(fallback.name, new PageGetByTextOptions { Exact = true }).First;
+ if (await textLocator.CountAsync().WaitAsync(cancellationToken) > 0
+ && await textLocator.IsVisibleAsync().WaitAsync(cancellationToken))
+ {
+ await ClickWithoutNavigationWaitAsync(
+ page,
+ textLocator,
+ treatAsSubmit ? $"submitting {fallback.name}" : $"clicking {fallback.name}",
+ cancellationToken);
+ return true;
+ }
+
+ if (throwOnFailure)
+ {
+ throw new TimeoutException($"Unable to click '{fallback.name}'.");
+ }
+
+ return false;
+ }
+
+ private async Task CaptureAfterInteractionAsync(
+ IPlaywrightSession session,
+ IPage page,
+ CancellationToken cancellationToken)
+ {
+ await CaptureAfterInteractionAsyncFromPageAsync(page, cancellationToken);
+ return await _observationService.CaptureAsync(session, cancellationToken);
+ }
+
+ private async Task CaptureAfterEditorActionAsync(
+ IPlaywrightSession session,
+ IPage page,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ await page.WaitForLoadStateAsync(LoadState.DOMContentLoaded, new PageWaitForLoadStateOptions
+ {
+ Timeout = 15_000,
+ }).WaitAsync(cancellationToken);
+ }
+ catch (TimeoutException)
+ {
+ // Some editor actions update in-place and never trigger a full navigation.
+ }
+
+ try
+ {
+ await page.WaitForLoadStateAsync(LoadState.NetworkIdle, new PageWaitForLoadStateOptions
+ {
+ Timeout = 5_000,
+ }).WaitAsync(cancellationToken);
+ }
+ catch (TimeoutException)
+ {
+ // Network activity can continue after the editor returns. Observation below is authoritative.
+ }
+
+ return await _observationService.CaptureAsync(session, cancellationToken);
+ }
+
+ private async Task CaptureAfterEditorActionFailureAsync(
+ IPlaywrightSession session,
+ IPage page,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ await page.WaitForLoadStateAsync(LoadState.DOMContentLoaded, new PageWaitForLoadStateOptions
+ {
+ Timeout = 2_000,
+ }).WaitAsync(cancellationToken);
+ }
+ catch (TimeoutException)
+ {
+ }
+
+ return await _observationService.CaptureAsync(session, cancellationToken);
+ }
+
+ private static string BuildEditorActionFailureMessage(
+ IReadOnlyList candidateNames,
+ PlaywrightObservation observation)
+ {
+ var details = new List
+ {
+ $"Action did not finish in time after clicking: {string.Join(", ", candidateNames)}.",
+ };
+
+ if (!string.IsNullOrWhiteSpace(observation?.MainHeading))
+ {
+ details.Add($"Heading: {observation.MainHeading}.");
+ }
+
+ if (!string.IsNullOrWhiteSpace(observation?.ToastMessage))
+ {
+ details.Add($"Toast: {observation.ToastMessage}.");
+ }
+
+ if (observation?.ValidationMessages?.Count > 0)
+ {
+ details.Add($"Messages: {string.Join(" | ", observation.ValidationMessages)}.");
+ }
+
+ if (!string.IsNullOrWhiteSpace(observation?.CurrentUrl))
+ {
+ details.Add($"URL: {observation.CurrentUrl}.");
+ }
+
+ if (observation?.VisibleButtons?.Count > 0)
+ {
+ details.Add($"Visible actions: {string.Join(", ", observation.VisibleButtons)}.");
+ }
+
+ return string.Join(" ", details);
+ }
+
+ private static async Task TrySetFieldValueAsync(
+ ILocator locator,
+ string label,
+ string value,
+ string requestedFieldType,
+ bool append,
+ CancellationToken cancellationToken)
+ {
+ if (await locator.CountAsync().WaitAsync(cancellationToken) == 0)
+ {
+ return null;
+ }
+
+ return await locator.EvaluateAsync(
+ """
+ (element, payload) => {
+ const normalize = (input) => (input ?? "").toString().trim().replace(/\s+/g, " ");
+ const isVisible = (candidate) => {
+ if (!candidate) {
+ return false;
+ }
+
+ const style = window.getComputedStyle(candidate);
+ if (style.visibility === "hidden" || style.display === "none") {
+ return false;
+ }
+
+ return candidate.offsetParent !== null || style.position === "fixed";
+ };
+
+ const dispatch = (candidate) => {
+ for (const eventName of ["input", "change", "blur"]) {
+ candidate.dispatchEvent(new Event(eventName, { bubbles: true }));
+ }
+ };
+
+ const appendPlainText = (currentValue, nextValue, separator) => {
+ if (!nextValue) {
+ return currentValue ?? "";
+ }
+
+ if (!currentValue) {
+ return nextValue;
+ }
+
+ return `${currentValue}${separator}${nextValue}`;
+ };
+
+ const findFirstVisible = (candidates) => candidates.find((candidate) => isVisible(candidate));
+
+ const parseBoolean = (input) => {
+ const normalized = normalize(input).toLowerCase();
+ if (["true", "1", "yes", "y", "on", "checked", "check", "selected"].includes(normalized)) {
+ return true;
+ }
+
+ if (["false", "0", "no", "n", "off", "unchecked", "uncheck", "clear"].includes(normalized)) {
+ return false;
+ }
+
+ return null;
+ };
+
+ const setStandardValue = (candidate, append) => {
+ if (!(candidate instanceof HTMLInputElement) && !(candidate instanceof HTMLTextAreaElement)) {
+ return null;
+ }
+
+ if (!isVisible(candidate)) {
+ return null;
+ }
+
+ const separator = candidate instanceof HTMLTextAreaElement || append ? "\n\n" : " ";
+ candidate.focus();
+ candidate.value = append ? appendPlainText(candidate.value, payload.value, separator) : payload.value;
+ dispatch(candidate);
+
+ if (candidate instanceof HTMLTextAreaElement) {
+ return "textarea";
+ }
+
+ return "input";
+ };
+
+ const setSelectValue = (candidate) => {
+ if (!(candidate instanceof HTMLSelectElement) || !isVisible(candidate)) {
+ return null;
+ }
+
+ const requestedValue = normalize(payload.value).toLowerCase();
+ const options = Array.from(candidate.options || []);
+ const matched = options.find((option) => {
+ return normalize(option.value).toLowerCase() === requestedValue
+ || normalize(option.label).toLowerCase() === requestedValue
+ || normalize(option.text).toLowerCase() === requestedValue;
+ }) || options.find((option) => {
+ return normalize(option.value).toLowerCase().includes(requestedValue)
+ || normalize(option.label).toLowerCase().includes(requestedValue)
+ || normalize(option.text).toLowerCase().includes(requestedValue);
+ });
+
+ if (!matched) {
+ return null;
+ }
+
+ candidate.value = matched.value;
+ dispatch(candidate);
+ return "select";
+ };
+
+ const setBooleanValue = (candidate) => {
+ if (!(candidate instanceof HTMLInputElement) || !isVisible(candidate)) {
+ return null;
+ }
+
+ if (candidate.type !== "checkbox" && candidate.type !== "radio") {
+ return null;
+ }
+
+ const nextValue = parseBoolean(payload.value);
+ if (nextValue === null) {
+ return null;
+ }
+
+ if (candidate.type === "radio" && nextValue === false) {
+ return null;
+ }
+
+ candidate.focus();
+ candidate.checked = nextValue;
+ dispatch(candidate);
+ return candidate.type;
+ };
+
+ const escapeHtml = (input) => {
+ const div = document.createElement("div");
+ div.textContent = input ?? "";
+ return div.innerHTML;
+ };
+
+ const toParagraphHtml = (input) => {
+ const lines = (input ?? "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
+ if (lines.length === 0) {
+ return "";
+ }
+
+ return lines.map((line) => `${escapeHtml(line)}
`).join("");
+ };
+
+ const appendRichText = (root, nextValue, append) => {
+ const htmlToAppend = toParagraphHtml(nextValue);
+ if (!htmlToAppend) {
+ return root.innerHTML ?? "";
+ }
+
+ if (append && normalize(root.innerText)) {
+ root.insertAdjacentHTML("beforeend", htmlToAppend);
+ } else {
+ root.innerHTML = htmlToAppend;
+ }
+
+ dispatch(root);
+ return root.innerHTML ?? "";
+ };
+
+ const syncSourceField = (content) => {
+ if (element instanceof HTMLTextAreaElement || element instanceof HTMLInputElement) {
+ element.value = content ?? "";
+ dispatch(element);
+ }
+ };
+
+ const fieldId = element.id || "";
+ const fieldName = element.getAttribute("name") || "";
+ const labelToken = normalize(payload.label).replace(/\s+/g, "").toLowerCase();
+ const fieldTokens = [fieldId, fieldName, payload.label, labelToken]
+ .filter(Boolean)
+ .map((token) => token.toString().toLowerCase());
+
+ const matchesField = (candidate) => {
+ const values = [
+ candidate?.id,
+ candidate?.getAttribute?.("name"),
+ candidate?.getAttribute?.("aria-label"),
+ candidate?.getAttribute?.("title"),
+ candidate?.getAttribute?.("data-field-name"),
+ candidate?.getAttribute?.("for"),
+ ]
+ .filter(Boolean)
+ .map((token) => token.toString().toLowerCase());
+
+ return fieldTokens.some((token) => values.some((value) => value.includes(token)));
+ };
+
+ const container = element.closest(".mb-3, .form-group, .field, fieldset, section, .card-body, .content-field")
+ || element.parentElement
+ || document.body;
+
+ const requestedKind = normalize(payload.requestedFieldType).toLowerCase();
+ const preferAppend = Boolean(payload.append);
+ const preferRichText = requestedKind === "richtext";
+ const preferSelect = requestedKind === "select";
+ const preferCheckbox = requestedKind === "checkbox" || requestedKind === "radio" || requestedKind === "boolean";
+ const directMode = setBooleanValue(element)
+ || setSelectValue(element)
+ || setStandardValue(
+ element,
+ preferAppend || element instanceof HTMLTextAreaElement || Number.parseInt(element.getAttribute?.("rows") || "0", 10) > 1);
+
+ if (directMode && (!preferSelect || directMode === "select") && (!preferCheckbox || directMode === requestedKind || directMode === "checkbox")) {
+ return directMode;
+ }
+
+ if (fieldId && window.tinymce && typeof window.tinymce.get === "function") {
+ const editor = window.tinymce.get(fieldId);
+ if (editor) {
+ const currentContent = editor.getContent({ format: "html" }) || "";
+ const nextHtml = toParagraphHtml(payload.value);
+ const combinedContent = preferAppend && currentContent
+ ? `${currentContent}${nextHtml}`
+ : nextHtml;
+
+ editor.setContent(combinedContent);
+ editor.save();
+ syncSourceField(combinedContent);
+ return "tinymce";
+ }
+ }
+
+ const standardCandidates = [
+ ...container.querySelectorAll("textarea, input, select"),
+ ...document.querySelectorAll("textarea, input, select"),
+ ];
+
+ for (const candidate of standardCandidates) {
+ if (candidate === element) {
+ continue;
+ }
+
+ if (!matchesField(candidate) && !isVisible(candidate)) {
+ continue;
+ }
+
+ const mode = setBooleanValue(candidate)
+ || setSelectValue(candidate)
+ || setStandardValue(
+ candidate,
+ preferAppend || candidate instanceof HTMLTextAreaElement || Number.parseInt(candidate.getAttribute("rows") || "0", 10) > 1);
+
+ if (preferSelect && mode !== "select") {
+ continue;
+ }
+
+ if (preferCheckbox && mode !== requestedKind && mode !== "checkbox") {
+ continue;
+ }
+
+ if (mode) {
+ syncSourceField(candidate.value ?? "");
+ return `${mode}-related`;
+ }
+ }
+
+ const editableCandidates = [
+ ...container.querySelectorAll("[contenteditable='true'], .ck-editor__editable, .ProseMirror, .trumbowyg-editor, .ql-editor"),
+ ...document.querySelectorAll("[contenteditable='true'], .ck-editor__editable, .ProseMirror, .trumbowyg-editor, .ql-editor"),
+ ];
+
+ const editable = findFirstVisible(editableCandidates.filter((candidate) => matchesField(candidate) || container.contains(candidate)));
+ if (editable && !preferSelect && !preferCheckbox) {
+ const updatedHtml = appendRichText(editable, payload.value, preferAppend || normalize(editable.innerText).length > 0);
+ syncSourceField(updatedHtml);
+ return "contenteditable";
+ }
+
+ const iframeCandidates = [
+ ...container.querySelectorAll("iframe"),
+ ...document.querySelectorAll("iframe"),
+ ].filter((candidate) => matchesField(candidate) || container.contains(candidate));
+
+ for (const iframe of iframeCandidates) {
+ if (!isVisible(iframe)) {
+ continue;
+ }
+
+ try {
+ const doc = iframe.contentDocument;
+ if (!doc?.body) {
+ continue;
+ }
+
+ const updatedHtml = appendRichText(doc.body, payload.value, preferAppend || normalize(doc.body.innerText).length > 0);
+ syncSourceField(updatedHtml);
+ return "iframe";
+ } catch {
+ // Ignore inaccessible iframe candidates and continue.
+ }
+ }
+
+ if (preferRichText) {
+ return null;
+ }
+
+ return null;
+ }
+ """,
+ new
+ {
+ label,
+ value,
+ append,
+ requestedFieldType,
+ }).WaitAsync(cancellationToken);
+ }
+
+ private static bool ShouldAppendToField(string label)
+ => label.Contains("body", StringComparison.OrdinalIgnoreCase)
+ || label.Contains("html", StringComparison.OrdinalIgnoreCase)
+ || label.Contains("content", StringComparison.OrdinalIgnoreCase)
+ || label.Contains("description", StringComparison.OrdinalIgnoreCase)
+ || label.Contains("summary", StringComparison.OrdinalIgnoreCase);
+
+ private static async Task> GetVisibleContentItemsAsync(
+ IPage page,
+ int maxItems,
+ CancellationToken cancellationToken)
+ {
+ maxItems = Math.Clamp(maxItems, 1, 25);
+
+ var payload = await page.EvaluateAsync(
+ """
+ (maxItems) => {
+ const normalize = (value) => (value || "").replace(/\s+/g, " ").trim();
+ const isVisible = (element) => {
+ if (!(element instanceof HTMLElement) || element.hidden) {
+ return false;
+ }
+
+ const style = window.getComputedStyle(element);
+ if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") {
+ return false;
+ }
+
+ return element.offsetWidth > 0 || element.offsetHeight > 0 || element.getClientRects().length > 0;
+ };
+
+ const isActionLine = (value) => /^(edit|view|preview|delete|clone|duplicate|save|publish|unpublish|actions|more|more actions|options)$/i.test(value);
+ const isStatusLine = (value) => /(draft|published|unpublished|latest|modified)/i.test(value);
+ const results = [];
+ const seen = new Set();
+ const rows = Array.from(document.querySelectorAll("tbody tr, table tr, [data-content-item-id], .content-item, .list-group-item, article"));
+
+ for (const row of rows) {
+ if (!isVisible(row)) {
+ continue;
+ }
+
+ const lines = normalize(row.innerText).split(/\s{2,}|\n+/).map(normalize).filter(Boolean);
+ const filteredLines = lines.filter((line) => !isActionLine(line));
+ const linkTexts = Array.from(row.querySelectorAll("a, button, strong, h2, h3, h4"))
+ .filter((element) => isVisible(element))
+ .map((element) => normalize(element.innerText || element.textContent || ""))
+ .filter((text) => text.length > 2 && text.length < 180 && !isActionLine(text));
+
+ const title = linkTexts.find((text) => !isStatusLine(text))
+ || filteredLines.find((line) => line.length > 2 && line.length < 180 && !isStatusLine(line))
+ || "";
+ if (!title) {
+ continue;
+ }
+
+ const titleKey = title.toLowerCase();
+ if (seen.has(titleKey)) {
+ continue;
+ }
+
+ seen.add(titleKey);
+ const status = filteredLines.find((line) => isStatusLine(line)) || "";
+ const contentType = filteredLines.find((line, index) => index > 0 && line !== title && line !== status && line.length < 100) || "";
+ const canEdit = Array.from(row.querySelectorAll("a, button, input[type='submit']")).some((element) => {
+ const text = normalize(element.innerText || element.value || element.getAttribute("aria-label") || element.getAttribute("title") || "");
+ return /edit/i.test(text);
+ });
+
+ results.push({
+ title,
+ contentType,
+ status,
+ canEdit
+ });
+
+ if (results.length >= maxItems) {
+ break;
+ }
+ }
+
+ return results;
+ }
+ """,
+ maxItems).WaitAsync(cancellationToken);
+
+ return (payload ?? [])
+ .Select(item => new PlaywrightContentListItem
+ {
+ Title = NormalizeTitle(item.Title),
+ ContentType = NormalizeTitle(item.ContentType),
+ Status = NormalizeTitle(item.Status),
+ CanEdit = item.CanEdit,
+ })
+ .Where(item => !string.IsNullOrWhiteSpace(item.Title))
+ .ToList();
+ }
+
+ private async Task ShowFillIndicatorAsync(
+ IPage page,
+ ILocator locator,
+ string label,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ await _actionVisualizer.ShowLocatorActionAsync(page, locator, "Typing", label, cancellationToken);
+ }
+ catch
+ {
+ await _actionVisualizer.ShowPageActionAsync(page, "Typing", label, cancellationToken);
+ }
+ }
+
+ private async Task ClickWithoutNavigationWaitAsync(
+ IPage page,
+ ILocator locator,
+ string actionDescription,
+ CancellationToken cancellationToken)
+ {
+ await locator.WaitForAsync(new LocatorWaitForOptions
+ {
+ State = WaitForSelectorState.Visible,
+ Timeout = 10_000,
+ }).WaitAsync(cancellationToken);
+
+ await _actionVisualizer.ShowLocatorActionAsync(page, locator, "AI", actionDescription, cancellationToken);
+ await locator.ScrollIntoViewIfNeededAsync().WaitAsync(cancellationToken);
+ await locator.ClickAsync(new LocatorClickOptions
+ {
+ Timeout = 10_000,
+ }).WaitAsync(cancellationToken);
+ }
+
+ private static async Task CaptureAfterInteractionAsyncFromPageAsync(IPage page, CancellationToken cancellationToken)
+ {
+ try
+ {
+ await page.WaitForLoadStateAsync(LoadState.DOMContentLoaded, new PageWaitForLoadStateOptions
+ {
+ Timeout = 10_000,
+ }).WaitAsync(cancellationToken);
+ }
+ catch (TimeoutException)
+ {
+ }
+ }
+
+ private static (int Score, string Mode) GetTitleMatch(string candidateTitle, string requestedTitle)
+ {
+ if (string.IsNullOrWhiteSpace(candidateTitle) || string.IsNullOrWhiteSpace(requestedTitle))
+ {
+ return (0, null);
+ }
+
+ if (candidateTitle.Equals(requestedTitle, StringComparison.OrdinalIgnoreCase))
+ {
+ return (1000, "Exact");
+ }
+
+ var normalizedCandidate = NormalizeTitleKey(candidateTitle);
+ var normalizedRequested = NormalizeTitleKey(requestedTitle);
+ if (string.IsNullOrWhiteSpace(normalizedCandidate) || string.IsNullOrWhiteSpace(normalizedRequested))
+ {
+ return (0, null);
+ }
+
+ if (normalizedCandidate.Equals(normalizedRequested, StringComparison.Ordinal))
+ {
+ return (950, "Normalized");
+ }
+
+ if (normalizedCandidate.Contains(normalizedRequested, StringComparison.Ordinal)
+ || normalizedRequested.Contains(normalizedCandidate, StringComparison.Ordinal))
+ {
+ return (860, "Contains");
+ }
+
+ var requestedTokens = TokenizeTitle(requestedTitle);
+ var candidateTokens = TokenizeTitle(candidateTitle);
+ var overlap = requestedTokens.Intersect(candidateTokens, StringComparer.OrdinalIgnoreCase).Count();
+
+ if (overlap == requestedTokens.Count && overlap > 0)
+ {
+ return (880, "TokenMatch");
+ }
+
+ if (overlap > 0)
+ {
+ return (700 + (overlap * 40), "PartialTokenMatch");
+ }
+
+ var distance = GetLevenshteinDistance(normalizedCandidate, normalizedRequested);
+ var threshold = Math.Max(2, Math.Max(normalizedCandidate.Length, normalizedRequested.Length) / 4);
+ if (distance <= threshold)
+ {
+ return (650 - (distance * 25), "Fuzzy");
+ }
+
+ return (0, null);
+ }
+
+ private static string NormalizeTitle(string value)
+ => string.IsNullOrWhiteSpace(value)
+ ? null
+ : Regex.Replace(value.Trim(), @"\s+", " ");
+
+ private static string NormalizeTitleKey(string value)
+ => string.Concat((value ?? string.Empty)
+ .Where(char.IsLetterOrDigit))
+ .ToLowerInvariant();
+
+ private static IReadOnlyList TokenizeTitle(string value)
+ => Regex.Split(value ?? string.Empty, @"[\s\-_:/\\]+")
+ .Select(token => token.Trim())
+ .Where(token => !string.IsNullOrWhiteSpace(token))
+ .ToList();
+
+ private static int GetLevenshteinDistance(string source, string target)
+ {
+ var matrix = new int[source.Length + 1, target.Length + 1];
+
+ for (var i = 0; i <= source.Length; i++)
+ {
+ matrix[i, 0] = i;
+ }
+
+ for (var j = 0; j <= target.Length; j++)
+ {
+ matrix[0, j] = j;
+ }
+
+ for (var i = 1; i <= source.Length; i++)
+ {
+ for (var j = 1; j <= target.Length; j++)
+ {
+ var cost = source[i - 1] == target[j - 1] ? 0 : 1;
+ matrix[i, j] = Math.Min(
+ Math.Min(
+ matrix[i - 1, j] + 1,
+ matrix[i, j - 1] + 1),
+ matrix[i - 1, j - 1] + cost);
+ }
+ }
+
+ return matrix[source.Length, target.Length];
+ }
+
+ private static async Task FindContentItemContainerAsync(
+ IPage page,
+ string contentItemTitle,
+ CancellationToken cancellationToken)
+ {
+ foreach (var exact in new[] { true, false })
+ {
+ foreach (var selector in ContentItemContainerSelectors)
+ {
+ var locator = page.Locator(selector).Filter(new LocatorFilterOptions
+ {
+ Has = page.GetByText(contentItemTitle, new PageGetByTextOptions { Exact = exact }),
+ }).First;
+
+ if (await locator.CountAsync().WaitAsync(cancellationToken) == 0)
+ {
+ continue;
+ }
+
+ if (await locator.IsVisibleAsync().WaitAsync(cancellationToken))
+ {
+ return locator;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ private async Task TryClickActionWithinContainerAsync(
+ IPage page,
+ ILocator container,
+ string actionName,
+ string contentItemTitle,
+ CancellationToken cancellationToken)
+ {
+ var exactCandidates = new ILocator[]
+ {
+ container.GetByRole(AriaRole.Link, new() { Name = actionName, Exact = true }).First,
+ container.GetByRole(AriaRole.Button, new() { Name = actionName, Exact = true }).First,
+ container.GetByText(actionName, new LocatorGetByTextOptions { Exact = true }).First,
+ };
+
+ foreach (var locator in exactCandidates)
+ {
+ if (await locator.CountAsync().WaitAsync(cancellationToken) == 0)
+ {
+ continue;
+ }
+
+ if (!await locator.IsVisibleAsync().WaitAsync(cancellationToken))
+ {
+ continue;
+ }
+
+ await ClickWithoutNavigationWaitAsync(page, locator, $"clicking {actionName} for {contentItemTitle}", cancellationToken);
+
+ return true;
+ }
+
+ var fallback = container.Locator(
+ $"a[title*='{actionName}' i], button[title*='{actionName}' i], a[aria-label*='{actionName}' i], button[aria-label*='{actionName}' i], input[type='submit'][value*='{actionName}' i]").First;
+
+ if (await fallback.CountAsync().WaitAsync(cancellationToken) == 0
+ || !await fallback.IsVisibleAsync().WaitAsync(cancellationToken))
+ {
+ return actionName.Equals("Edit", StringComparison.OrdinalIgnoreCase)
+ && await TryOpenContentItemTitleWithinContainerAsync(page, container, contentItemTitle, cancellationToken);
+ }
+
+ await ClickWithoutNavigationWaitAsync(page, fallback, $"clicking {actionName} for {contentItemTitle}", cancellationToken);
+
+ return true;
+ }
+
+ private async Task TryOpenContentItemTitleWithinContainerAsync(
+ IPage page,
+ ILocator container,
+ string contentItemTitle,
+ CancellationToken cancellationToken)
+ {
+ foreach (var exact in new[] { true, false })
+ {
+ foreach (var locator in new[]
+ {
+ container.GetByRole(AriaRole.Link, new() { Name = contentItemTitle, Exact = exact }).First,
+ container.GetByRole(AriaRole.Button, new() { Name = contentItemTitle, Exact = exact }).First,
+ container.GetByText(contentItemTitle, new LocatorGetByTextOptions { Exact = exact }).First,
+ })
+ {
+ if (await locator.CountAsync().WaitAsync(cancellationToken) == 0
+ || !await locator.IsVisibleAsync().WaitAsync(cancellationToken))
+ {
+ continue;
+ }
+
+ await ClickWithoutNavigationWaitAsync(page, locator, $"opening {contentItemTitle}", cancellationToken);
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private sealed class VisibleContentItemPayload
+ {
+ public string Title { get; set; }
+
+ public string ContentType { get; set; }
+
+ public string Status { get; set; }
+
+ public bool CanEdit { get; set; }
+ }
+
+ private sealed class ContentItemSelection
+ {
+ public PlaywrightContentListItem Item { get; init; }
+
+ public string MatchMode { get; init; }
+
+ public int Score { get; init; }
+ }
+
+ private sealed class EditorTargetMatch
+ {
+ public ILocator Locator { get; init; }
+
+ public string MatchedText { get; init; }
+
+ public string TargetKind { get; init; }
+ }
+
+ private static async Task WaitForEditorAsync(IPage page, CancellationToken cancellationToken)
+ {
+ var titleLocator = page.GetByLabel("Title", new() { Exact = true }).First;
+
+ try
+ {
+ await titleLocator.WaitForAsync(new LocatorWaitForOptions
+ {
+ State = WaitForSelectorState.Visible,
+ Timeout = 15_000,
+ }).WaitAsync(cancellationToken);
+ }
+ catch (TimeoutException)
+ {
+ var fallback = page.Locator("input[name='TitlePart.Title'], input[id*='TitlePart_Title']").First;
+ await fallback.WaitForAsync(new LocatorWaitForOptions
+ {
+ State = WaitForSelectorState.Visible,
+ Timeout = 15_000,
+ }).WaitAsync(cancellationToken);
+ }
+ }
+}
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Playwright/Services/OrchardEvidenceService.cs b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Services/OrchardEvidenceService.cs
new file mode 100644
index 000000000..38ab24948
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.AI.Playwright/Services/OrchardEvidenceService.cs
@@ -0,0 +1,266 @@
+using CrestApps.OrchardCore.AI.Playwright.Models;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Playwright;
+using OrchardClock = OrchardCore.Modules.IClock;
+
+namespace CrestApps.OrchardCore.AI.Playwright.Services;
+
+///
+/// Locates a named OrchardCore admin action using 5-tier priority locator strategies.
+/// Captures structured evidence (screenshots, HTML) when the action is not found.
+///
+public sealed class OrchardEvidenceService : IOrchardEvidenceService
+{
+ // OrchardCore container selectors tried in order when capturing a container screenshot.
+ private static readonly string[] _containerSelectors =
+ [
+ ".content-item-actions",
+ "[class*='actions']",
+ ".dropdown-menu.show",
+ ".card-footer",
+ ".card-header",
+ ".sticky-top",
+ "main",
+ "form",
+ ".content",
+ ];
+
+ private readonly OrchardClock _clock;
+ private readonly IHostEnvironment _hostEnvironment;
+ private readonly ILogger _logger;
+
+ public OrchardEvidenceService(
+ OrchardClock clock,
+ IHostEnvironment hostEnvironment,
+ ILogger logger)
+ {
+ _clock = clock;
+ _hostEnvironment = hostEnvironment;
+ _logger = logger;
+ }
+
+ public async Task FindOrchardElementWithEvidenceAsync(
+ IPlaywrightSession session,
+ string actionLabel,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(session);
+ ArgumentException.ThrowIfNullOrWhiteSpace(actionLabel);
+
+ var page = GetPage(session);
+ var url = page.Url;
+ var title = await page.TitleAsync().WaitAsync(cancellationToken);
+ var strategies = BuildLocatorStrategies(page, actionLabel);
+ var attempts = new List(strategies.Count);
+
+ _logger.LogDebug(
+ "Searching for OrchardCore action '{ActionLabel}' using {StrategyCount} locator strategies for session '{SessionId}'.",
+ actionLabel,
+ strategies.Count,
+ session.SessionId);
+
+ foreach (var (name, locator) in strategies)
+ {
+ attempts.Add(name);
+
+ try
+ {
+ var count = await locator.CountAsync().WaitAsync(cancellationToken);
+ if (count > 0)
+ {
+ _logger.LogDebug(
+ "Found OrchardCore action '{ActionLabel}' via strategy '{StrategyName}' ({Count} match(es)) for session '{SessionId}'.",
+ actionLabel,
+ name,
+ count,
+ session.SessionId);
+
+ return new PlaywrightElementDiagnosticsResult
+ {
+ Found = true,
+ MatchedLocator = name,
+ Url = url,
+ Title = title,
+ Attempts = attempts,
+ };
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(
+ ex,
+ "Locator strategy '{StrategyName}' threw for action '{ActionLabel}' in session '{SessionId}'.",
+ name,
+ actionLabel,
+ session.SessionId);
+ }
+ }
+
+ // All strategies exhausted — capture diagnostic evidence.
+ _logger.LogInformation(
+ "OrchardCore action '{ActionLabel}' not found after {StrategyCount} strategies. Capturing evidence for session '{SessionId}'.",
+ actionLabel,
+ strategies.Count,
+ session.SessionId);
+
+ var (pagePath, containerPath, htmlPath) = await CaptureEvidenceAsync(page, session.SessionId, cancellationToken);
+
+ return new PlaywrightElementDiagnosticsResult
+ {
+ Found = false,
+ Url = url,
+ Title = title,
+ PageScreenshotPath = pagePath,
+ ContainerScreenshotPath = containerPath,
+ PageHtmlPath = htmlPath,
+ Attempts = attempts,
+ };
+ }
+
+ // -------------------------------------------------------------------------
+ // Locator strategies — OrchardCore-specific, 5 tiers in priority order
+ // -------------------------------------------------------------------------
+
+ private static IReadOnlyList<(string Name, ILocator Locator)> BuildLocatorStrategies(IPage page, string actionLabel)
+ {
+ var escaped = actionLabel.Replace("'", "\\'");
+
+ return
+ [
+ // Tier 1: Role link — Edit / Preview appear as in content list rows
+ (
+ $"role:link[name='{actionLabel}']",
+ page.GetByRole(AriaRole.Link, new() { Name = actionLabel, Exact = true })
+ ),
+
+ // Tier 2: Role button — Publish Now / Save Draft are