@poe-platform/safe-bash 0.1.679 → 0.1.681

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/README.md +1 -1
  2. package/dist/safe-bash/commands/playwright/index.d.ts.map +1 -1
  3. package/dist/safe-bash/commands/playwright/index.js +77 -4
  4. package/dist/safe-bash/commands/playwright/index.js.map +1 -1
  5. package/dist/safe-bash/commands/text-programs/awk-runtime.d.ts.map +1 -1
  6. package/dist/safe-bash/commands/text-programs/awk-runtime.js +1 -3
  7. package/dist/safe-bash/commands/text-programs/awk-runtime.js.map +1 -1
  8. package/dist/safe-bash/commands/text-programs/regex.d.ts +3 -1
  9. package/dist/safe-bash/commands/text-programs/regex.d.ts.map +1 -1
  10. package/dist/safe-bash/commands/text-programs/regex.js +13 -7
  11. package/dist/safe-bash/commands/text-programs/regex.js.map +1 -1
  12. package/dist/safe-bash/core.browser.js +12 -9
  13. package/dist/safe-bash/core.browser.js.map +2 -2
  14. package/dist/safe-bash/playwright/abilities.d.ts +24 -5
  15. package/dist/safe-bash/playwright/abilities.d.ts.map +1 -1
  16. package/dist/safe-bash/playwright/abilities.js +9 -4
  17. package/dist/safe-bash/playwright/abilities.js.map +1 -1
  18. package/dist/safe-bash/playwright/ability-execution.d.ts +6 -1
  19. package/dist/safe-bash/playwright/ability-execution.d.ts.map +1 -1
  20. package/dist/safe-bash/playwright/ability-execution.js +77 -5
  21. package/dist/safe-bash/playwright/ability-execution.js.map +1 -1
  22. package/dist/safe-bash/playwright/adapter.d.ts +467 -11
  23. package/dist/safe-bash/playwright/adapter.d.ts.map +1 -1
  24. package/dist/safe-bash/playwright/adapter.js +213 -4
  25. package/dist/safe-bash/playwright/adapter.js.map +1 -1
  26. package/dist/safe-bash/playwright/builtins.d.ts +44 -4
  27. package/dist/safe-bash/playwright/builtins.d.ts.map +1 -1
  28. package/dist/safe-bash/playwright/builtins.js +14 -9
  29. package/dist/safe-bash/playwright/builtins.js.map +1 -1
  30. package/dist/safe-bash/playwright/canonical-locator-parser.d.ts +5 -0
  31. package/dist/safe-bash/playwright/canonical-locator-parser.d.ts.map +1 -0
  32. package/dist/safe-bash/playwright/canonical-locator-parser.js +146 -0
  33. package/dist/safe-bash/playwright/canonical-locator-parser.js.map +1 -0
  34. package/dist/safe-bash/playwright/capability-events.d.ts +15 -0
  35. package/dist/safe-bash/playwright/capability-events.d.ts.map +1 -0
  36. package/dist/safe-bash/playwright/capability-events.js +265 -0
  37. package/dist/safe-bash/playwright/capability-events.js.map +1 -0
  38. package/dist/safe-bash/playwright/capability-result.d.ts +33 -0
  39. package/dist/safe-bash/playwright/capability-result.d.ts.map +1 -0
  40. package/dist/safe-bash/playwright/capability-result.js +72 -0
  41. package/dist/safe-bash/playwright/capability-result.js.map +1 -0
  42. package/dist/safe-bash/playwright/config-ini.d.ts +3 -0
  43. package/dist/safe-bash/playwright/config-ini.d.ts.map +1 -0
  44. package/dist/safe-bash/playwright/config-ini.js +161 -0
  45. package/dist/safe-bash/playwright/config-ini.js.map +1 -0
  46. package/dist/safe-bash/playwright/controller.d.ts +39 -1
  47. package/dist/safe-bash/playwright/controller.d.ts.map +1 -1
  48. package/dist/safe-bash/playwright/controller.js +913 -77
  49. package/dist/safe-bash/playwright/controller.js.map +1 -1
  50. package/dist/safe-bash/playwright/devices.d.ts +229 -0
  51. package/dist/safe-bash/playwright/devices.d.ts.map +1 -0
  52. package/dist/safe-bash/playwright/devices.js +2740 -0
  53. package/dist/safe-bash/playwright/devices.js.map +1 -0
  54. package/dist/safe-bash/playwright/download-capabilities.d.ts +15 -0
  55. package/dist/safe-bash/playwright/download-capabilities.d.ts.map +1 -0
  56. package/dist/safe-bash/playwright/download-capabilities.js +161 -0
  57. package/dist/safe-bash/playwright/download-capabilities.js.map +1 -0
  58. package/dist/safe-bash/playwright/drop-capability.d.ts +3 -0
  59. package/dist/safe-bash/playwright/drop-capability.d.ts.map +1 -0
  60. package/dist/safe-bash/playwright/drop-capability.js +50 -0
  61. package/dist/safe-bash/playwright/drop-capability.js.map +1 -0
  62. package/dist/safe-bash/playwright/find.d.ts +8 -0
  63. package/dist/safe-bash/playwright/find.d.ts.map +1 -0
  64. package/dist/safe-bash/playwright/find.js +68 -0
  65. package/dist/safe-bash/playwright/find.js.map +1 -0
  66. package/dist/safe-bash/playwright/generated-locator.d.ts +4 -0
  67. package/dist/safe-bash/playwright/generated-locator.d.ts.map +1 -0
  68. package/dist/safe-bash/playwright/generated-locator.js +22 -0
  69. package/dist/safe-bash/playwright/generated-locator.js.map +1 -0
  70. package/dist/safe-bash/playwright/help.d.ts +4 -3
  71. package/dist/safe-bash/playwright/help.d.ts.map +1 -1
  72. package/dist/safe-bash/playwright/help.js +7 -52
  73. package/dist/safe-bash/playwright/help.js.map +1 -1
  74. package/dist/safe-bash/playwright/highlight.d.ts +8 -0
  75. package/dist/safe-bash/playwright/highlight.d.ts.map +1 -0
  76. package/dist/safe-bash/playwright/highlight.js +82 -0
  77. package/dist/safe-bash/playwright/highlight.js.map +1 -0
  78. package/dist/safe-bash/playwright/index.d.ts +5 -0
  79. package/dist/safe-bash/playwright/index.d.ts.map +1 -1
  80. package/dist/safe-bash/playwright/index.js +4 -0
  81. package/dist/safe-bash/playwright/index.js.map +1 -1
  82. package/dist/safe-bash/playwright/invocation.d.ts +24 -0
  83. package/dist/safe-bash/playwright/invocation.d.ts.map +1 -1
  84. package/dist/safe-bash/playwright/invocation.js +42 -10
  85. package/dist/safe-bash/playwright/invocation.js.map +1 -1
  86. package/dist/safe-bash/playwright/locator-selector.d.ts +5 -0
  87. package/dist/safe-bash/playwright/locator-selector.d.ts.map +1 -0
  88. package/dist/safe-bash/playwright/locator-selector.js +70 -0
  89. package/dist/safe-bash/playwright/locator-selector.js.map +1 -0
  90. package/dist/safe-bash/playwright/modal-capabilities.d.ts +20 -0
  91. package/dist/safe-bash/playwright/modal-capabilities.d.ts.map +1 -0
  92. package/dist/safe-bash/playwright/modal-capabilities.js +137 -0
  93. package/dist/safe-bash/playwright/modal-capabilities.js.map +1 -0
  94. package/dist/safe-bash/playwright/native-json-snapshot.d.ts +17 -0
  95. package/dist/safe-bash/playwright/native-json-snapshot.d.ts.map +1 -0
  96. package/dist/safe-bash/playwright/native-json-snapshot.js +94 -0
  97. package/dist/safe-bash/playwright/native-json-snapshot.js.map +1 -0
  98. package/dist/safe-bash/playwright/native-snapshot.d.ts +17 -0
  99. package/dist/safe-bash/playwright/native-snapshot.d.ts.map +1 -0
  100. package/dist/safe-bash/playwright/native-snapshot.js +164 -0
  101. package/dist/safe-bash/playwright/native-snapshot.js.map +1 -0
  102. package/dist/safe-bash/playwright/network-policy.d.ts +2 -0
  103. package/dist/safe-bash/playwright/network-policy.d.ts.map +1 -1
  104. package/dist/safe-bash/playwright/network-policy.js +1 -1
  105. package/dist/safe-bash/playwright/network-policy.js.map +1 -1
  106. package/dist/safe-bash/playwright/open-options.d.ts +14 -0
  107. package/dist/safe-bash/playwright/open-options.d.ts.map +1 -0
  108. package/dist/safe-bash/playwright/open-options.js +279 -0
  109. package/dist/safe-bash/playwright/open-options.js.map +1 -0
  110. package/dist/safe-bash/playwright/recording-capabilities.d.ts +6 -0
  111. package/dist/safe-bash/playwright/recording-capabilities.d.ts.map +1 -0
  112. package/dist/safe-bash/playwright/recording-capabilities.js +83 -0
  113. package/dist/safe-bash/playwright/recording-capabilities.js.map +1 -0
  114. package/dist/safe-bash/playwright/resource-limit.d.ts +6 -0
  115. package/dist/safe-bash/playwright/resource-limit.d.ts.map +1 -0
  116. package/dist/safe-bash/playwright/resource-limit.js +8 -0
  117. package/dist/safe-bash/playwright/resource-limit.js.map +1 -0
  118. package/dist/safe-bash/playwright/response.d.ts +22 -0
  119. package/dist/safe-bash/playwright/response.d.ts.map +1 -0
  120. package/dist/safe-bash/playwright/response.js +43 -0
  121. package/dist/safe-bash/playwright/response.js.map +1 -0
  122. package/dist/safe-bash/playwright/route-capabilities.d.ts +25 -0
  123. package/dist/safe-bash/playwright/route-capabilities.d.ts.map +1 -0
  124. package/dist/safe-bash/playwright/route-capabilities.js +138 -0
  125. package/dist/safe-bash/playwright/route-capabilities.js.map +1 -0
  126. package/dist/safe-bash/playwright/screenshot.d.ts +3 -1
  127. package/dist/safe-bash/playwright/screenshot.d.ts.map +1 -1
  128. package/dist/safe-bash/playwright/screenshot.js +15 -12
  129. package/dist/safe-bash/playwright/screenshot.js.map +1 -1
  130. package/dist/safe-bash/playwright/session-configuration.d.ts +35 -0
  131. package/dist/safe-bash/playwright/session-configuration.d.ts.map +1 -0
  132. package/dist/safe-bash/playwright/session-configuration.js +50 -0
  133. package/dist/safe-bash/playwright/session-configuration.js.map +1 -0
  134. package/dist/safe-bash/playwright/session-runtime.d.ts +8 -0
  135. package/dist/safe-bash/playwright/session-runtime.d.ts.map +1 -0
  136. package/dist/safe-bash/playwright/session-runtime.js +96 -0
  137. package/dist/safe-bash/playwright/session-runtime.js.map +1 -0
  138. package/dist/safe-bash/playwright/snapshot.d.ts +15 -2
  139. package/dist/safe-bash/playwright/snapshot.d.ts.map +1 -1
  140. package/dist/safe-bash/playwright/snapshot.js +66 -7
  141. package/dist/safe-bash/playwright/snapshot.js.map +1 -1
  142. package/dist/safe-bash/playwright/standard-capabilities.d.ts +4 -0
  143. package/dist/safe-bash/playwright/standard-capabilities.d.ts.map +1 -0
  144. package/dist/safe-bash/playwright/standard-capabilities.js +248 -0
  145. package/dist/safe-bash/playwright/standard-capabilities.js.map +1 -0
  146. package/dist/safe-bash/playwright/storage-capabilities.d.ts +4 -0
  147. package/dist/safe-bash/playwright/storage-capabilities.d.ts.map +1 -0
  148. package/dist/safe-bash/playwright/storage-capabilities.js +120 -0
  149. package/dist/safe-bash/playwright/storage-capabilities.js.map +1 -0
  150. package/dist/safe-bash/playwright/storage-state.d.ts +8 -0
  151. package/dist/safe-bash/playwright/storage-state.d.ts.map +1 -0
  152. package/dist/safe-bash/playwright/storage-state.js +122 -0
  153. package/dist/safe-bash/playwright/storage-state.js.map +1 -0
  154. package/dist/safe-bash/playwright/target-screenshot.d.ts +9 -0
  155. package/dist/safe-bash/playwright/target-screenshot.d.ts.map +1 -0
  156. package/dist/safe-bash/playwright/target-screenshot.js +28 -0
  157. package/dist/safe-bash/playwright/target-screenshot.js.map +1 -0
  158. package/dist/safe-bash/playwright/targets.d.ts +12 -0
  159. package/dist/safe-bash/playwright/targets.d.ts.map +1 -0
  160. package/dist/safe-bash/playwright/targets.js +36 -0
  161. package/dist/safe-bash/playwright/targets.js.map +1 -0
  162. package/dist/safe-bash/playwright/tracing-capabilities.d.ts +12 -0
  163. package/dist/safe-bash/playwright/tracing-capabilities.d.ts.map +1 -0
  164. package/dist/safe-bash/playwright/tracing-capabilities.js +100 -0
  165. package/dist/safe-bash/playwright/tracing-capabilities.js.map +1 -0
  166. package/dist/safe-bash/playwright/webmcp-capabilities.d.ts +6 -0
  167. package/dist/safe-bash/playwright/webmcp-capabilities.d.ts.map +1 -0
  168. package/dist/safe-bash/playwright/webmcp-capabilities.js +122 -0
  169. package/dist/safe-bash/playwright/webmcp-capabilities.js.map +1 -0
  170. package/dist/safe-bash/playwright/workspace-assets.d.ts +5 -0
  171. package/dist/safe-bash/playwright/workspace-assets.d.ts.map +1 -0
  172. package/dist/safe-bash/playwright/workspace-assets.js +17 -0
  173. package/dist/safe-bash/playwright/workspace-assets.js.map +1 -0
  174. package/dist/safe-bash/playwright/workspace.d.ts +3 -0
  175. package/dist/safe-bash/playwright/workspace.d.ts.map +1 -0
  176. package/dist/safe-bash/playwright/workspace.js +48 -0
  177. package/dist/safe-bash/playwright/workspace.js.map +1 -0
  178. package/package.json +2 -2
@@ -4,19 +4,45 @@ import { parseInvocation, validatePlaywrightSessionName } from './invocation.js'
4
4
  import { formatPlaywrightHelp } from './help.js';
5
5
  import { registerPlaywrightAbilities } from './abilities.js';
6
6
  import { executePlaywrightAbility } from './ability-execution.js';
7
+ import { playwrightCliCompatibilityVersion, playwrightCodeString, serializePlaywrightResult } from './response.js';
8
+ import { capabilityArtifactName } from './capability-result.js';
9
+ import { isPlaywrightSnapshotRef, resolvePlaywrightTarget } from './targets.js';
10
+ import { capturePlaywrightTargetScreenshot } from './target-screenshot.js';
11
+ import { flushPlaywrightConsole, observePlaywrightCapabilities } from './capability-events.js';
12
+ import { getPlaywrightModal, observePlaywrightModals, onPlaywrightModal } from './modal-capabilities.js';
13
+ import { findPlaywrightSnapshot } from './find.js';
14
+ import { collectPlaywrightDownloads, observePlaywrightDownloads } from './download-capabilities.js';
15
+ import { generatedPlaywrightLocator } from './generated-locator.js';
16
+ import { capturePlaywrightRoutes } from './route-capabilities.js';
17
+ import { updatePlaywrightHighlight } from './highlight.js';
18
+ import { parsePlaywrightContextOptions, resolvePlaywrightOpenOptions } from './open-options.js';
19
+ import { playwrightLocatorSelector, setPlaywrightTestIdAttribute } from './locator-selector.js';
20
+ import { initializePlaywrightWorkspace } from './workspace.js';
21
+ import { parsePlaywrightSessionConfiguration } from './session-configuration.js';
22
+ import { installPlaywrightConfiguredNetwork, playwrightInitPageSource } from './session-runtime.js';
23
+ import { flushPlaywrightTrace, preparePlaywrightTraceRelease } from './tracing-capabilities.js';
24
+ import { resolvePath } from '../contracts/path.js';
7
25
  export function createPlaywrightController(options = {}) {
8
26
  if (!options || typeof options !== 'object')
9
27
  throw new TypeError('Invalid Playwright configuration');
10
28
  if (options.adapter !== undefined && (!options.adapter || typeof options.adapter.acquire !== 'function'))
11
29
  throw new TypeError('An injected Playwright adapter is required');
12
- if (Object.keys(options).some(key => !['adapter', 'abilities', 'limits', 'billing'].includes(key)))
30
+ if (Object.keys(options).some(key => !['adapter', 'abilities', 'limits', 'billing', 'persistence'].includes(key)))
13
31
  throw new TypeError('Unsupported Playwright configuration');
14
- if (options.limits !== undefined && (!options.limits || typeof options.limits !== 'object' || Object.keys(options.limits).some(key => !['maxSessions', 'actionTimeoutMs', 'maxSnapshotBytes', 'maxSnapshotRefs', 'maxArtifactBytes', 'maxTabs', 'maxCommandBytes'].includes(key))))
32
+ if (options.persistence && ['restore', 'checkpoint', 'delete'].some(key => typeof Reflect.get(options.persistence, key) !== 'function'))
33
+ throw new TypeError('Invalid Playwright persistence');
34
+ if (options.persistence?.close !== undefined && typeof options.persistence.close !== 'function')
35
+ throw new TypeError('Invalid Playwright persistence close');
36
+ if (options.persistence?.list !== undefined && typeof options.persistence.list !== 'function')
37
+ throw new TypeError('Invalid Playwright persistence list');
38
+ if (options.limits !== undefined && (!options.limits || typeof options.limits !== 'object' || Object.keys(options.limits).some(key => !['maxSessions', 'actionTimeoutMs', 'codeExecutionTimeoutMs', 'maxSnapshotBytes', 'maxSnapshotRefs', 'maxArtifactBytes', 'maxTabs', 'maxCommandBytes'].includes(key))))
15
39
  throw new TypeError('Unsupported Playwright limits');
16
40
  if (options.billing !== undefined)
17
41
  throw new Error('Live billing is not implemented');
18
42
  const maxSessions = options.limits?.maxSessions ?? 4;
19
- const actionTimeoutMs = options.limits?.actionTimeoutMs ?? 30_000;
43
+ const actionTimeoutMs = options.limits?.actionTimeoutMs ?? 5_000;
44
+ // Isolated startup and multiple native actions share this host budget, not one action's timeout.
45
+ const codeExecutionTimeoutMs = options.limits?.codeExecutionTimeoutMs ?? 30_000;
20
46
  const maxSnapshotBytes = options.limits?.maxSnapshotBytes ?? 256 * 1024;
21
47
  const maxSnapshotRefs = options.limits?.maxSnapshotRefs ?? 1000;
22
48
  const maxArtifactBytes = options.limits?.maxArtifactBytes ?? 16 * 1024 * 1024;
@@ -24,30 +50,86 @@ export function createPlaywrightController(options = {}) {
24
50
  const maxCommandBytes = options.limits?.maxCommandBytes ?? 16 * 1024 * 1024;
25
51
  const abilities = registerPlaywrightAbilities(options.abilities, options.adapter !== undefined);
26
52
  let refSequence = 0;
27
- for (const value of [maxSessions, actionTimeoutMs, maxSnapshotBytes, maxSnapshotRefs, maxArtifactBytes, maxTabs, maxCommandBytes])
53
+ for (const value of [maxSessions, actionTimeoutMs, codeExecutionTimeoutMs, maxSnapshotBytes, maxSnapshotRefs, maxArtifactBytes, maxTabs, maxCommandBytes])
28
54
  if (!Number.isSafeInteger(value) || value < 1)
29
55
  throw new RangeError('Invalid Playwright limit');
30
56
  const sessions = new Map();
57
+ const explicitlyClosed = new Set();
58
+ let suppressUnknownRestores = false;
31
59
  const tails = new Map();
32
60
  const work = new Set();
33
61
  const lifetime = new AbortController();
34
62
  let generation = 0;
35
63
  let disposal;
64
+ const sessionActionTimeout = (session) => session?.configuration?.timeouts?.action ?? actionTimeoutMs;
65
+ const sessionNavigationTimeout = (session) => session?.configuration?.timeouts?.navigation ?? options.limits?.actionTimeoutMs ?? 60_000;
66
+ const initializeContext = async (session) => {
67
+ if (session.configuration?.codegen && !['typescript', 'none'].includes(session.configuration.codegen) && !session.lease?.generateActionCode)
68
+ throw new Error('Configured codegen language requires native action generation');
69
+ await installPlaywrightConfiguredNetwork(session.lease.context, session.configuration);
70
+ const configured = session.lease.context;
71
+ configured.setDefaultTimeout?.(sessionActionTimeout(session));
72
+ configured.setDefaultNavigationTimeout?.(sessionNavigationTimeout(session));
73
+ const scripts = session.configuration?.initScripts;
74
+ if (!scripts?.length)
75
+ return;
76
+ const context = session.lease.context;
77
+ if (!context.addInitScript)
78
+ throw new Error('Browser context init scripts unsupported');
79
+ // One native registration preserves source order across every future page.
80
+ await context.addInitScript({ content: scripts.join('\n;\n') });
81
+ };
82
+ const savedSessions = async (signal) => {
83
+ const entries = await options.persistence?.list?.(signal) ?? [];
84
+ signal.throwIfAborted();
85
+ if (!Array.isArray(entries) || entries.length > maxSnapshotRefs)
86
+ throw new PlaywrightResourceLimitError('Playwright session list limit exceeded');
87
+ for (const entry of entries) {
88
+ if (!entry || typeof entry !== 'object')
89
+ throw new Error('Invalid persisted session');
90
+ validatePlaywrightSessionName(entry.name);
91
+ if (entry.expiresAt !== undefined && (!Number.isSafeInteger(entry.expiresAt) || entry.expiresAt < 0))
92
+ throw new Error('Invalid persisted session expiry');
93
+ }
94
+ return entries.filter(entry => !explicitlyClosed.has(entry.name) && (!suppressUnknownRestores || sessions.get(entry.name)?.state === 'open') && (entry.expiresAt === undefined || entry.expiresAt > Date.now()));
95
+ };
96
+ const checkpoint = async (session, signal, activity = true) => {
97
+ if (activity && session.idleTimeoutMs)
98
+ session.expiresAt = Date.now() + session.idleTimeoutMs;
99
+ scheduleExpiry(session);
100
+ if (!options.persistence || session.state !== 'open' || !session.lease || session.failure)
101
+ return;
102
+ if (session.lease.context.pages().some(page => getPlaywrightModal(page)))
103
+ return;
104
+ signal.throwIfAborted();
105
+ await options.persistence.checkpoint({ name: session.name, context: session.lease.context,
106
+ ...(session.page ? { selectedPage: session.page } : {}), ...(session.expiresAt === undefined ? {} : { expiresAt: session.expiresAt }),
107
+ ...(session.idleTimeoutMs === undefined ? {} : { idleTimeoutMs: session.idleTimeoutMs }),
108
+ ...(session.contextOptions === undefined ? {} : { contextOptions: structuredClone(session.contextOptions) }),
109
+ ...(session.configuration === undefined ? {} : { configuration: structuredClone(session.configuration) }) }, signal);
110
+ signal.throwIfAborted();
111
+ };
36
112
  const release = (session) => {
37
113
  if (session.releasing)
38
114
  return session.releasing;
39
115
  session.state = 'closing';
116
+ clearTimeout(session.expiryTimer);
40
117
  session.releasing = Promise.resolve().then(async () => {
41
118
  try {
119
+ // Native tracing must flush while the context is still alive. Arbitrary
120
+ // cleanup remains concurrent with lease closure to unblock browser work.
121
+ const traceResults = session.lease ? await Promise.allSettled([preparePlaywrightTraceRelease(session.lease.context)]) : [];
42
122
  const callbacks = [...session.cleanups];
43
123
  session.cleanups.clear();
44
124
  const custom = callbacks.map(cleanup => Promise.resolve().then(cleanup));
45
125
  const results = await Promise.allSettled([
46
- Promise.resolve().then(() => session.detachPage?.()), ...custom,
126
+ Promise.resolve().then(() => session.detachPage?.()), Promise.resolve().then(() => session.detachContext?.()), Promise.resolve().then(() => session.disposeCapabilities?.()), ...custom,
47
127
  session.snapshot.invalidate(true), Promise.resolve().then(() => session.unsubscribe?.()),
48
128
  Promise.resolve().then(() => session.lease?.release()),
129
+ ...[...session.pendingActions ?? []].map(action => action.catch(() => { })),
130
+ ...[...session.pendingInitializations ?? []].map(action => action.catch(() => { })),
49
131
  ]);
50
- const errors = results.flatMap(result => result.status === 'rejected' ? [result.reason] : []);
132
+ const errors = [...traceResults, ...results].flatMap(result => result.status === 'rejected' ? [result.reason] : []);
51
133
  if (errors.length === 1)
52
134
  throw errors[0];
53
135
  if (errors.length > 1)
@@ -62,6 +144,25 @@ export function createPlaywrightController(options = {}) {
62
144
  void session.releasing.catch(() => { });
63
145
  return session.releasing;
64
146
  };
147
+ const checkpointAndRelease = async (session, signal) => {
148
+ const errors = [];
149
+ try {
150
+ await checkpoint(session, signal, false);
151
+ }
152
+ catch (error) {
153
+ errors.push(error);
154
+ }
155
+ try {
156
+ await release(session);
157
+ }
158
+ catch (error) {
159
+ errors.push(error);
160
+ }
161
+ if (errors.length === 1)
162
+ throw errors[0];
163
+ if (errors.length)
164
+ throw new AggregateError(errors, 'Playwright checkpoint and retirement failed');
165
+ };
65
166
  const enqueue = (name, task) => {
66
167
  const operation = (tails.get(name) ?? Promise.resolve()).then(task);
67
168
  const tail = operation.then(() => { }, () => { });
@@ -73,10 +174,12 @@ export function createPlaywrightController(options = {}) {
73
174
  const enforceTabLimit = (session) => {
74
175
  if (!session.lease || session.releasing || session.lease.context.pages().length <= maxTabs)
75
176
  return;
76
- session.failure = new Error('Playwright tab limit exceeded');
177
+ session.failure = new PlaywrightResourceLimitError('Playwright tab limit exceeded');
77
178
  void release(session).catch(() => { });
78
179
  };
79
180
  const selectPage = async (session, page, check) => {
181
+ check();
182
+ await initializePage(session, page);
80
183
  check();
81
184
  session.detachPage?.();
82
185
  await session.snapshot.invalidate();
@@ -91,6 +194,50 @@ export function createPlaywrightController(options = {}) {
91
194
  page.on('close', closed);
92
195
  }
93
196
  };
197
+ const initializePage = async (session, page) => {
198
+ const key = page.mainFrame?.() ?? page;
199
+ setPlaywrightTestIdAttribute(page, session.configuration?.testIdAttribute);
200
+ session.pageInitializations ??= new WeakMap();
201
+ let operation = session.pageInitializations.get(key);
202
+ if (!operation) {
203
+ operation = (async () => {
204
+ for (const module of session.configuration?.initPages ?? []) {
205
+ if (!session.lease?.executeCode)
206
+ throw new Error('Browser initPage requires isolated native code execution');
207
+ try {
208
+ await session.lease.executeCode({ page, source: playwrightInitPageSource(module.source), signal: lifetime.signal,
209
+ timeoutMs: codeExecutionTimeoutMs, maxOutputBytes: maxCommandBytes, maxPages: maxTabs });
210
+ }
211
+ catch (cause) {
212
+ throw new Error(`Failed to load init page "${module.filename}": ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
213
+ }
214
+ }
215
+ })();
216
+ session.pageInitializations.set(key, operation);
217
+ (session.pendingInitializations ??= new Set()).add(operation);
218
+ void operation.then(() => { session.pendingInitializations.delete(operation); }, error => { session.pendingInitializations.delete(operation); session.failure = error instanceof Error ? error : new Error(String(error)); });
219
+ }
220
+ await operation;
221
+ };
222
+ const observeContext = (session) => {
223
+ session.detachContext?.();
224
+ const context = session.lease.context;
225
+ const onPage = (page) => { enforceTabLimit(session); void initializePage(session, page).catch(() => { }); };
226
+ session.detachContext = () => { context.off('page', onPage); };
227
+ const cleanups = [];
228
+ session.disposeCapabilities = async () => {
229
+ const results = await Promise.allSettled(cleanups.map(cleanup => Promise.resolve().then(cleanup)));
230
+ const errors = results.flatMap(result => result.status === 'rejected' ? [result.reason] : []);
231
+ if (errors.length === 1)
232
+ throw errors[0];
233
+ if (errors.length)
234
+ throw new AggregateError(errors, 'Browser observation cleanup failed');
235
+ };
236
+ observePlaywrightCapabilities(context, cleanup => cleanups.push(cleanup), { maxCommandBytes, maxArtifactBytes });
237
+ observePlaywrightModals(context, cleanup => cleanups.push(cleanup));
238
+ observePlaywrightDownloads(context, cleanup => cleanups.push(cleanup), { maxMetadataBytes: maxCommandBytes });
239
+ context.on('page', onPage);
240
+ };
94
241
  const observeSession = (session) => {
95
242
  const unsubscribe = session.lease.onClosed(() => {
96
243
  if (sessions.get(session.name) !== session || session.state === 'closed')
@@ -104,17 +251,15 @@ export function createPlaywrightController(options = {}) {
104
251
  unsubscribe();
105
252
  throw new Error(`Session closed: ${session.name}`);
106
253
  }
107
- const context = session.lease.context;
108
- const onPage = () => enforceTabLimit(session);
109
- session.cleanups.add(async () => { context.off('page', onPage); });
110
- context.on('page', onPage);
254
+ observeContext(session);
111
255
  };
112
256
  const retireExpired = () => {
113
257
  const now = Date.now();
114
- const expired = [...sessions.values()].filter(session => session.state === 'open' && session.expiresAt !== undefined && session.expiresAt <= now);
258
+ const expired = [...sessions.values()].filter(session => session.state === 'open' && !session.idlePaused && session.expiresAt !== undefined && session.expiresAt <= now);
115
259
  if (!expired.length)
116
260
  return undefined;
117
261
  return Promise.allSettled(expired.map(session => {
262
+ explicitlyClosed.add(session.name);
118
263
  session.failure = new Error(`Session expired: ${session.name}; reopen explicitly`);
119
264
  return release(session);
120
265
  })).then(results => {
@@ -123,18 +268,36 @@ export function createPlaywrightController(options = {}) {
123
268
  throw new AggregateError(errors, 'Playwright expiry cleanup failed');
124
269
  });
125
270
  };
271
+ const scheduleExpiry = (session) => {
272
+ clearTimeout(session.expiryTimer);
273
+ if (session.expiresAt === undefined || session.state !== 'open')
274
+ return;
275
+ session.expiryTimer = setTimeout(() => {
276
+ void enqueue(session.name, async () => {
277
+ await retireExpired();
278
+ if (session.state === 'open')
279
+ scheduleExpiry(session);
280
+ }).catch(() => { });
281
+ }, Math.min(2_147_483_647, Math.max(0, session.expiresAt - Date.now())));
282
+ session.expiryTimer.unref?.();
283
+ };
126
284
  const inspectSessions = () => Object.freeze([...sessions.values()]
127
285
  .filter(session => session.state === 'open' && (session.expiresAt === undefined || session.expiresAt > Date.now()))
128
286
  .map(session => Object.freeze({ name: session.name, context: session.lease.context,
129
287
  ...(session.page === undefined ? {} : { selectedPage: session.page }),
130
- ...(session.expiresAt === undefined ? {} : { expiresAt: session.expiresAt }), })));
131
- const restoreSession = async (request) => {
132
- if (!request || typeof request !== 'object' || Object.keys(request).some(key => !['name', 'expiresAt', 'signal', 'acquire'].includes(key))
288
+ ...(session.expiresAt === undefined ? {} : { expiresAt: session.expiresAt }),
289
+ ...(session.idleTimeoutMs === undefined ? {} : { idleTimeoutMs: session.idleTimeoutMs }),
290
+ ...(session.contextOptions === undefined ? {} : { contextOptions: structuredClone(session.contextOptions) }),
291
+ ...(session.configuration === undefined ? {} : { configuration: structuredClone(session.configuration) }), })));
292
+ const restoreSession = async (request, withinQueue = false) => {
293
+ if (!request || typeof request !== 'object' || Object.keys(request).some(key => !['name', 'expiresAt', 'idleTimeoutMs', 'contextOptions', 'configuration', 'signal', 'acquire'].includes(key))
133
294
  || typeof request.acquire !== 'function' || request.signal !== undefined && typeof request.signal.throwIfAborted !== 'function')
134
295
  throw new TypeError('Invalid Playwright session restoration');
135
296
  validatePlaywrightSessionName(request.name);
136
297
  if (request.expiresAt !== undefined && (!Number.isSafeInteger(request.expiresAt) || request.expiresAt < 0))
137
298
  throw new TypeError('Invalid Playwright session expiry');
299
+ if (request.idleTimeoutMs !== undefined && (!Number.isSafeInteger(request.idleTimeoutMs) || request.idleTimeoutMs < 0))
300
+ throw new TypeError('Invalid Playwright idle timeout');
138
301
  const { name, expiresAt, acquire } = request;
139
302
  const signal = request.signal ? AbortSignal.any([request.signal, lifetime.signal]) : lifetime.signal;
140
303
  const check = () => {
@@ -143,7 +306,7 @@ export function createPlaywrightController(options = {}) {
143
306
  throw new Error(`Session expired: ${name}; reopen explicitly`);
144
307
  };
145
308
  check();
146
- const operation = enqueue(name, async () => {
309
+ const restore = async () => {
147
310
  check();
148
311
  await retireExpired();
149
312
  const previous = sessions.get(name);
@@ -153,13 +316,18 @@ export function createPlaywrightController(options = {}) {
153
316
  await previous.releasing;
154
317
  check();
155
318
  if ([...sessions.values()].filter(session => session.state !== 'closed').length >= maxSessions)
156
- throw new Error('Playwright session capacity exceeded');
319
+ throw new PlaywrightResourceLimitError('Playwright session capacity exceeded');
157
320
  const epoch = Array.from(crypto.getRandomValues(new Uint32Array(4)), value => String(value).padStart(10, '0')).join('');
158
321
  const session = { name, generation: ++generation, state: 'acquiring', cleanups: new Set(),
159
322
  snapshot: createSnapshotEngine({ maxSnapshotBytes, maxSnapshotRefs }, () => `e${epoch}${++refSequence}`),
160
323
  ...(expiresAt === undefined ? {} : { expiresAt }),
324
+ ...(request.idleTimeoutMs === undefined ? {} : { idleTimeoutMs: request.idleTimeoutMs }),
325
+ ...(request.contextOptions === undefined ? {} : { contextOptions: parsePlaywrightContextOptions(request.contextOptions, maxArtifactBytes) }),
326
+ ...(request.configuration === undefined ? {} : { configuration: parsePlaywrightSessionConfiguration(request.configuration, maxArtifactBytes) }),
161
327
  };
162
328
  sessions.set(name, session);
329
+ const cancel = () => { if (session.lease)
330
+ void release(session).catch(() => { }); };
163
331
  try {
164
332
  const restored = await acquire.call(request, { signal });
165
333
  session.lease = restored.lease;
@@ -167,25 +335,34 @@ export function createPlaywrightController(options = {}) {
167
335
  if (!session.lease || typeof session.lease.release !== 'function' || typeof session.lease.onClosed !== 'function'
168
336
  || !session.lease.context || ['pages', 'newPage', 'close', 'on', 'off'].some(key => typeof Reflect.get(session.lease.context, key) !== 'function'))
169
337
  throw new TypeError('Invalid restored Playwright lease');
338
+ signal.addEventListener('abort', cancel, { once: true });
170
339
  observeSession(session);
340
+ await initializeContext(session);
171
341
  const pages = [...session.lease.context.pages()];
172
342
  if (pages.length > maxTabs)
173
- throw new Error('Playwright tab limit exceeded');
343
+ throw new PlaywrightResourceLimitError('Playwright tab limit exceeded');
174
344
  if (restored.selectedPage !== undefined) {
175
345
  if (!pages.includes(restored.selectedPage))
176
346
  throw new Error('Selected tab does not belong to the restored context');
177
347
  await selectPage(session, restored.selectedPage, check);
178
348
  }
179
349
  check();
350
+ if (restored.initialize !== undefined) {
351
+ if (typeof restored.initialize !== 'function')
352
+ throw new TypeError('Invalid Playwright session initializer');
353
+ await restored.initialize({ signal });
354
+ check();
355
+ }
180
356
  if (session.releasing)
181
357
  throw new Error(`Session closed: ${name}`);
182
358
  const currentPages = [...session.lease.context.pages()];
183
359
  if (currentPages.length > maxTabs)
184
- throw new Error('Playwright tab limit exceeded');
360
+ throw new PlaywrightResourceLimitError('Playwright tab limit exceeded');
185
361
  if (restored.selectedPage !== undefined && !currentPages.includes(restored.selectedPage))
186
362
  throw new Error('Selected tab closed during restoration');
187
363
  session.pages = currentPages;
188
364
  session.state = 'open';
365
+ scheduleExpiry(session);
189
366
  }
190
367
  catch (error) {
191
368
  const reason = signal.aborted ? signal.reason : error;
@@ -197,7 +374,11 @@ export function createPlaywrightController(options = {}) {
197
374
  }
198
375
  throw reason;
199
376
  }
200
- });
377
+ finally {
378
+ signal.removeEventListener('abort', cancel);
379
+ }
380
+ };
381
+ const operation = withinQueue ? restore() : enqueue(name, restore);
201
382
  work.add(operation);
202
383
  try {
203
384
  await operation;
@@ -207,23 +388,41 @@ export function createPlaywrightController(options = {}) {
207
388
  }
208
389
  };
209
390
  const run = async (invocation) => {
391
+ const writtenFiles = new Set();
392
+ const original = invocation;
393
+ if (original.writeArtifact)
394
+ invocation = { ...original, async writeArtifact(bytes, filename) {
395
+ await original.writeArtifact(bytes, filename);
396
+ if (filename !== undefined)
397
+ writtenFiles.add(resolvePath(original.workspace?.cwd ?? '/', filename));
398
+ } };
210
399
  if (lifetime.signal.aborted)
211
400
  throw new Error('Playwright controller is disposed');
212
401
  const parsed = parseInvocation(invocation, abilities, options.adapter);
213
402
  invocation.signal.throwIfAborted();
214
403
  const local = new AbortController();
215
404
  let active;
405
+ let traceFlushed = false;
216
406
  let retained = false;
217
407
  let finished = false;
218
408
  let cleanupCompletion;
409
+ const ownedTargets = new Set();
410
+ const targetLocators = new Map();
219
411
  const cleanup = () => {
220
412
  if (cleanupCompletion)
221
413
  return cleanupCompletion;
222
414
  // Install completion before abort dispatch can reenter cleanup.
223
415
  cleanupCompletion = Promise.resolve().then(async () => {
224
416
  await operation.catch(() => { });
417
+ const retirements = [...ownedTargets].map(handle => handle.dispose());
225
418
  if (active && (!retained || active.releasing))
226
- await release(active);
419
+ retirements.push(release(active));
420
+ const settled = await Promise.allSettled(retirements);
421
+ const errors = settled.flatMap(result => result.status === 'rejected' ? [result.reason] : []);
422
+ if (errors.length === 1)
423
+ throw errors[0];
424
+ if (errors.length)
425
+ throw new AggregateError(errors, 'Playwright invocation cleanup failed');
227
426
  });
228
427
  void cleanupCompletion.catch(() => { });
229
428
  // Close admission immediately and unblock cooperative browser work before
@@ -250,9 +449,171 @@ export function createPlaywrightController(options = {}) {
250
449
  local.signal.throwIfAborted();
251
450
  lifetime.signal.throwIfAborted();
252
451
  };
452
+ const writeResult = async (result) => {
453
+ check();
454
+ const text = serializePlaywrightResult(configuredResult(result), parsed);
455
+ if (text.length > maxCommandBytes || new TextEncoder().encode(text).byteLength > maxCommandBytes)
456
+ throw new PlaywrightResourceLimitError('Playwright command byte limit exceeded');
457
+ await invocation.write(text);
458
+ check();
459
+ };
460
+ const configuredResult = (result) => {
461
+ const language = active?.configuration?.codegen ?? 'typescript';
462
+ return { sections: result.sections.flatMap(section => section.title !== 'Ran Playwright code' ? [section] : language === 'none' ? [] : [{ ...section, codeframe: language === 'typescript' ? 'js' : language }]) };
463
+ };
464
+ const actionCode = (session, action, fallback) => {
465
+ const language = session.configuration?.codegen ?? 'typescript';
466
+ if (language === 'none')
467
+ return '';
468
+ if (!session.lease?.generateActionCode) {
469
+ if (language !== 'typescript')
470
+ throw new Error('Configured codegen language requires native action generation');
471
+ return fallback;
472
+ }
473
+ const code = session.lease.generateActionCode({ language, action });
474
+ if (typeof code !== 'string' || new TextEncoder().encode(code).byteLength > maxCommandBytes)
475
+ throw new PlaywrightResourceLimitError('Playwright generated code byte limit exceeded');
476
+ return code;
477
+ };
478
+ const enforceOutputBudget = async () => {
479
+ const limit = active?.configuration?.outputMaxSize;
480
+ if (!limit)
481
+ return;
482
+ const workspace = invocation.workspace;
483
+ if (!workspace?.listFiles || !workspace.removeFile)
484
+ throw new Error('Playwright outputMaxSize requires virtual workspace retention support');
485
+ const directory = resolvePath(workspace.cwd, active?.configuration?.outputDir ?? '.playwright-cli');
486
+ const files = await workspace.listFiles(directory, 4096);
487
+ check();
488
+ if (!Array.isArray(files) || files.length > 4096)
489
+ throw new PlaywrightResourceLimitError('Playwright output directory entry limit exceeded');
490
+ const unique = new Set();
491
+ let total = 0;
492
+ for (const entry of files) {
493
+ const name = resolvePath(workspace.cwd, entry.filename);
494
+ if (!name.startsWith(directory.endsWith('/') ? directory : directory + '/') || unique.has(name) || !Number.isSafeInteger(entry.size) || entry.size < 0 || !Number.isFinite(entry.mtimeMs))
495
+ throw new Error('Invalid Playwright output file metadata');
496
+ unique.add(name);
497
+ total += entry.size;
498
+ }
499
+ for (const entry of [...files].sort((a, b) => a.mtimeMs - b.mtimeMs)) {
500
+ if (total <= limit)
501
+ break;
502
+ const filename = resolvePath(workspace.cwd, entry.filename);
503
+ if (writtenFiles.has(filename))
504
+ continue;
505
+ await workspace.removeFile(filename);
506
+ check();
507
+ total -= entry.size;
508
+ }
509
+ };
510
+ const downloadSections = async (session) => {
511
+ if (!session.page)
512
+ return [];
513
+ const downloads = await collectPlaywrightDownloads(session.page, session.lease?.captureArtifact, { signal: local.signal, maxBytes: Math.min(maxArtifactBytes, maxCommandBytes), ...(session.configuration ? { configuration: session.configuration } : {}) }, session.lease?.captureDownload);
514
+ if (!downloads.length)
515
+ return [];
516
+ if (!invocation.writeArtifact)
517
+ throw new Error('Artifact byte destination unsupported');
518
+ for (const download of downloads)
519
+ await invocation.writeArtifact(download.bytes, download.filename);
520
+ return [{ title: 'Events', content: downloads.map(download => `- Downloaded file [${download.filename.split('/').at(-1)}](${download.filename})`).join('\n') }];
521
+ };
522
+ const flushTrace = async (session) => {
523
+ if (!session.lease?.captureTrace)
524
+ return [];
525
+ return flushPlaywrightTrace(session.lease.context, session.lease.captureTrace, {
526
+ signal: local.signal, maxBytes: Math.min(maxArtifactBytes, maxCommandBytes), async writeArtifact(bytes, filename) {
527
+ if (!invocation.writeArtifact)
528
+ throw new Error('Artifact byte destination unsupported');
529
+ await invocation.writeArtifact(bytes, filename);
530
+ },
531
+ ...(invocation.workspace ? { mkdir: (path) => invocation.workspace.mkdir(path) } : {}),
532
+ });
533
+ };
534
+ const retireForCommand = async (session, force = false) => {
535
+ const errors = [];
536
+ if (!force)
537
+ try {
538
+ await flushTrace(session);
539
+ }
540
+ catch (error) {
541
+ errors.push(error);
542
+ }
543
+ try {
544
+ await (force ? release(session) : checkpointAndRelease(session, local.signal));
545
+ }
546
+ catch (error) {
547
+ errors.push(error);
548
+ }
549
+ if (errors.length === 1)
550
+ throw errors[0];
551
+ if (errors.length)
552
+ throw new AggregateError(errors, 'Playwright trace flush and retirement failed');
553
+ };
554
+ const pageResult = async (session, code, snapshot = 'none', filename) => {
555
+ const sections = [];
556
+ if (code)
557
+ sections.push({ title: 'Ran Playwright code', content: code, codeframe: 'js' });
558
+ const page = session.page;
559
+ if (!page)
560
+ return { sections };
561
+ const modal = getPlaywrightModal(page);
562
+ if (modal) {
563
+ sections.push({ title: 'Modal state', content: modal.kind === 'dialog'
564
+ ? `- ["${modal.dialog.type()}" dialog with message "${modal.dialog.message()}"]: can be handled by dialog-accept or dialog-dismiss`
565
+ : '- [File chooser]: can be handled by upload' });
566
+ return { sections };
567
+ }
568
+ sections.push(...await downloadSections(session));
569
+ if (invocation.writeArtifact) {
570
+ const consoleLink = await flushPlaywrightConsole(session.lease.context, page, { ...(session.configuration ? { configuration: session.configuration } : {}), writeArtifact: async (bytes, filename) => { await invocation.writeArtifact(bytes, filename); } });
571
+ if (consoleLink)
572
+ sections.push({ title: 'Events', content: `- New console entries: ${consoleLink}` });
573
+ }
574
+ const title = await page.title?.();
575
+ sections.push({ title: 'Page', content: `- Page URL: ${page.url()}${title === undefined ? '' : `\n- Page Title: ${title}`}` });
576
+ if (snapshot !== 'none' && (page.frames || page.ariaSnapshot || page._snapshotForAI || page.ariaSnapshotJSON || session.lease?.captureSnapshotJSON)) {
577
+ const snapshotOptions = { timeout: sessionActionTimeout(session), ...(session.configuration?.snapshot?.boxes === undefined ? {} : { boxes: session.configuration.snapshot.boxes }) };
578
+ if (parsed.command === 'snapshot') {
579
+ if (parsed.options.depth !== undefined) {
580
+ const depth = Number(parsed.options.depth);
581
+ if (!Number.isSafeInteger(depth) || depth < 0)
582
+ throw new Error('Invalid snapshot depth');
583
+ snapshotOptions.depth = depth;
584
+ }
585
+ if (parsed.options.boxes)
586
+ snapshotOptions.boxes = true;
587
+ if (parsed.args[0])
588
+ snapshotOptions.root = await resolveTarget(session, parsed.args[0]);
589
+ }
590
+ if (parsed.command === 'snapshot' && parsed.json && filename === undefined) {
591
+ retained = false;
592
+ const tree = await session.snapshot.captureJSON(page, local.signal, { ...snapshotOptions, ...(session.lease?.captureSnapshotJSON ? { captureJSON: session.lease.captureSnapshotJSON } : {}) });
593
+ check();
594
+ sections.push({ title: 'Snapshot', content: { json: tree }, codeframe: 'json' });
595
+ return { sections };
596
+ }
597
+ const text = await session.snapshot.capture(page, local.signal, snapshotOptions);
598
+ check();
599
+ if (filename !== undefined || snapshot === 'file' && invocation.writeArtifact) {
600
+ const target = filename ?? capabilityArtifactName('page', 'yml', session.configuration);
601
+ const bytes = new TextEncoder().encode(text);
602
+ if (bytes.byteLength > maxArtifactBytes)
603
+ throw new PlaywrightResourceLimitError('Artifact byte limit exceeded');
604
+ if (!invocation.writeArtifact)
605
+ throw new Error('Artifact byte destination unsupported');
606
+ await invocation.writeArtifact(bytes, target);
607
+ sections.push({ title: 'Snapshot', content: `- [Snapshot](${target})` });
608
+ }
609
+ else
610
+ sections.push({ title: 'Snapshot', content: text.trimEnd(), codeframe: 'yaml' });
611
+ }
612
+ return { sections };
613
+ };
253
614
  const checkSession = (session) => {
254
615
  check();
255
- if (session.expiresAt !== undefined && session.expiresAt <= Date.now()) {
616
+ if (!session.idlePaused && session.expiresAt !== undefined && session.expiresAt <= Date.now()) {
256
617
  session.failure = new Error(`Session expired: ${session.name}; reopen explicitly`);
257
618
  void release(session).catch(() => { });
258
619
  }
@@ -260,39 +621,271 @@ export function createPlaywrightController(options = {}) {
260
621
  if (session.releasing)
261
622
  throw session.failure ?? new Error(`Session closed: ${session.name}; reopen explicitly`);
262
623
  };
624
+ const resolveTarget = async (session, target) => {
625
+ checkSession(session);
626
+ if (!session.page)
627
+ throw new Error('Selected tab closed; select a tab explicitly');
628
+ const handle = await resolvePlaywrightTarget({ target, page: session.page, timeout: sessionActionTimeout(session), signal: local.signal,
629
+ resolveRef: ref => session.snapshot.resolve(ref), own: handle => ownedTargets.add(handle) });
630
+ checkSession(session);
631
+ if (!targetLocators.has(target)) {
632
+ const reference = isPlaywrightSnapshotRef(target);
633
+ const selector = reference ? session.snapshot.nativeSelector(target) : target;
634
+ let description = `locator(${playwrightCodeString(selector ?? `aria-ref=${target}`)})`;
635
+ if (selector && session.page.locator) {
636
+ const locator = session.page.locator(reference ? selector : playwrightLocatorSelector(session.page, selector));
637
+ if (reference ? locator.normalize || locator._resolveSelector : locator.toString() !== '[object Object]')
638
+ description = await generatedPlaywrightLocator(session.page, selector, reference);
639
+ }
640
+ targetLocators.set(target, `page.${description}`);
641
+ }
642
+ return handle;
643
+ };
644
+ const canRetainAfterError = (session, error) => {
645
+ if (!session || session.state !== 'open' || !session.lease || session.failure || session.releasing || local.signal.aborted
646
+ || isPlaywrightResourceLimitError(error) || session.pendingActions?.size)
647
+ return false;
648
+ try {
649
+ const pages = session.lease.context.pages();
650
+ return pages.length <= maxTabs && (!session.page || pages.includes(session.page));
651
+ }
652
+ catch {
653
+ return false;
654
+ }
655
+ };
656
+ const runAction = async (session, action) => {
657
+ checkSession(session);
658
+ const page = session.page;
659
+ if (!page)
660
+ throw new Error('Selected tab closed; select a tab explicitly');
661
+ if (getPlaywrightModal(page)) {
662
+ retained = true;
663
+ throw new Error(`Tool "${parsed.command}" does not handle the modal state.`);
664
+ }
665
+ await Promise.all([...session.pendingActions ?? []]);
666
+ checkSession(session);
667
+ let yielded;
668
+ const modal = new Promise(resolve => { yielded = resolve; });
669
+ const unsubscribe = onPlaywrightModal(page, value => { if (value)
670
+ yielded(); });
671
+ const targets = [];
672
+ let settled = false;
673
+ let returnedForModal = false;
674
+ const operation = (async () => {
675
+ const errors = [];
676
+ try {
677
+ await session.snapshot.withReferences(async () => {
678
+ await action();
679
+ // The native CLI allows queued browser events to settle after an action.
680
+ await page.waitForTimeout?.(session.configuration?.timeouts?.settle ?? 500);
681
+ });
682
+ }
683
+ catch (error) {
684
+ errors.push(error);
685
+ }
686
+ settled = true;
687
+ const results = await Promise.allSettled(targets.map(target => target.dispose()));
688
+ errors.push(...results.flatMap(result => result.status === 'rejected' ? [result.reason] : []));
689
+ if (errors.length === 1)
690
+ throw errors[0];
691
+ if (errors.length)
692
+ throw new AggregateError(errors, 'Playwright action and target cleanup failed');
693
+ })();
694
+ (session.pendingActions ??= new Set()).add(operation);
695
+ void operation.then(() => { session.pendingActions.delete(operation); }, error => {
696
+ session.pendingActions.delete(operation);
697
+ if (returnedForModal && getPlaywrightModal(page) && error instanceof Error && error.name === 'TimeoutError')
698
+ return;
699
+ if (!session.releasing && (returnedForModal || isPlaywrightResourceLimitError(error))) {
700
+ session.failure = error instanceof Error ? error : new Error(String(error));
701
+ void release(session).catch(() => { });
702
+ }
703
+ });
704
+ try {
705
+ if (getPlaywrightModal(page))
706
+ yielded();
707
+ await Promise.race([operation, modal]);
708
+ checkSession(session);
709
+ returnedForModal = !!getPlaywrightModal(page);
710
+ if (!settled) {
711
+ targets.push(...ownedTargets);
712
+ ownedTargets.clear();
713
+ }
714
+ }
715
+ catch (error) {
716
+ if (canRetainAfterError(session, error))
717
+ retained = true;
718
+ throw error;
719
+ }
720
+ finally {
721
+ unsubscribe();
722
+ }
723
+ };
724
+ const replaceContext = async (session, state) => {
725
+ checkSession(session);
726
+ if (!session.lease?.replaceContext)
727
+ throw new Error('Storage state restoration is unavailable in this browser');
728
+ const restoreRoutes = capturePlaywrightRoutes(session.lease.context);
729
+ const pages = session.lease.context.pages();
730
+ const selected = session.page ? pages.indexOf(session.page) : -1;
731
+ const urls = pages.map(page => page.url());
732
+ session.detachPage?.();
733
+ session.detachContext?.();
734
+ await session.disposeCapabilities?.();
735
+ await session.snapshot.invalidate();
736
+ const context = await session.lease.replaceContext(state, { signal: local.signal });
737
+ checkSession(session);
738
+ await initializeContext(session);
739
+ observeContext(session);
740
+ delete session.page;
741
+ for (const [index, url] of urls.entries()) {
742
+ if (context.pages().length >= maxTabs)
743
+ throw new PlaywrightResourceLimitError('Playwright tab limit exceeded');
744
+ const page = await context.newPage();
745
+ await initializePage(session, page);
746
+ if (index === 0)
747
+ await restoreRoutes?.(context, cleanup => session.cleanups.add(cleanup));
748
+ if (url !== 'about:blank')
749
+ await page.goto(url, { timeout: sessionNavigationTimeout(session) });
750
+ if (index === selected)
751
+ await selectPage(session, page, () => checkSession(session));
752
+ }
753
+ session.pages = [...context.pages()];
754
+ checkSession(session);
755
+ };
263
756
  const execute = async () => {
264
757
  check();
265
758
  if (parsed.command === 'help') {
266
- await invocation.write(formatPlaywrightHelp(parsed.topic, abilities));
759
+ const help = formatPlaywrightHelp(parsed.topic, abilities);
760
+ await invocation.write(parsed.json ? JSON.stringify({ help: help.trimEnd() }, null, 2) + '\n' : help);
761
+ check();
762
+ return;
763
+ }
764
+ if (parsed.command === 'version') {
765
+ await invocation.write(parsed.json ? JSON.stringify({ version: playwrightCliCompatibilityVersion }, null, 2) + '\n' : playwrightCliCompatibilityVersion + '\n');
267
766
  check();
268
767
  return;
269
768
  }
270
769
  const ability = abilities.get(parsed.command);
770
+ if (parsed.command === 'attach' && !ability.execute) {
771
+ const targets = [parsed.args[0], parsed.options.cdp, parsed.options.endpoint, parsed.options.extension].filter(Boolean);
772
+ if (targets.length > 1)
773
+ throw new Error('only one of [name], --cdp, --endpoint, or --extension can be specified');
774
+ if (!targets.length || parsed.options.extension === true)
775
+ throw new Error('no target specified for attach command; use one of [name], --cdp, --endpoint, or --extension to specify the target to attach to.');
776
+ throw new Error('An authenticated browser attachment broker is unavailable in this host');
777
+ }
778
+ if (parsed.command === 'detach' && !ability.execute) {
779
+ const known = sessions.has(parsed.session) || (await savedSessions(local.signal)).some(session => session.name === parsed.session);
780
+ if (known)
781
+ throw new Error(parsed.json ? `session '${parsed.session}' was not attached; use close to stop it.` : `session '${parsed.session}' was not attached; use \`playwright-cli${parsed.session === 'default' ? '' : ` -s=${parsed.session}`} close\` to stop it.`);
782
+ await invocation.write(parsed.json ? JSON.stringify({ session: parsed.session, status: 'not-attached' }, null, 2) + '\n' : `Browser '${parsed.session}' is not attached.\n`);
783
+ return;
784
+ }
785
+ if (parsed.command === 'install' && !ability.execute) {
786
+ await initializePlaywrightWorkspace(parsed.options, invocation, Math.min(maxArtifactBytes, maxCommandBytes));
787
+ return;
788
+ }
789
+ if (parsed.command === 'install-browser' && !ability.execute) {
790
+ const browser = parsed.args[0] === 'chrome' ? 'chromium' : parsed.args[0] ?? 'chromium';
791
+ if (!options.adapter || !Object.hasOwn(options.adapter.browsers, browser))
792
+ throw new Error(`Unsupported browser: ${browser}`);
793
+ if (parsed.options.force || parsed.options['with-deps'])
794
+ throw new Error('Browser installation is managed by the configured provider');
795
+ if (parsed.options.list || parsed.options['dry-run'])
796
+ await invocation.write(`Browser: ${browser}\n Install location: provider-managed\n`);
797
+ return;
798
+ }
271
799
  const expiryRetirement = retireExpired();
272
800
  if (expiryRetirement)
273
801
  await expiryRetirement;
274
802
  check();
275
803
  if (parsed.command === 'list' && !ability.execute) {
276
- await invocation.write([...sessions.values()].map(s => `${s.name}\t${s.state}\n`).join(''));
804
+ const saved = await savedSessions(local.signal);
805
+ const known = new Map();
806
+ for (const entry of saved) {
807
+ known.set(entry.name, { name: entry.name, status: 'open' });
808
+ }
809
+ for (const session of sessions.values())
810
+ if (session.state === 'open')
811
+ known.set(session.name, { name: session.name, status: session.state });
812
+ const browsers = [...known.values()];
813
+ await invocation.write(parsed.json ? JSON.stringify({ browsers }, null, 2) + '\n' : browsers.length ? '### Browsers\n' + browsers.map(browser => `- ${browser.name}:\n - status: ${browser.status}\n`).join('') : ' (no browsers)\n');
277
814
  check();
278
815
  return;
279
816
  }
280
- if (parsed.command === 'close-all' && !ability.execute) {
817
+ if ((parsed.command === 'close-all' || parsed.command === 'kill-all') && !ability.execute) {
818
+ const saved = await savedSessions(local.signal);
819
+ suppressUnknownRestores = true;
820
+ const closed = [...new Set([...saved.map(session => session.name), ...[...sessions.values()].filter(session => session.state === 'open').map(session => session.name)])];
821
+ if (parsed.command === 'kill-all')
822
+ for (const session of sessions.values())
823
+ void release(session).catch(() => { });
281
824
  const results = await Promise.allSettled([...new Set([...sessions.keys(), ...tails.keys()])].map(name => enqueue(name, async () => {
282
825
  check();
826
+ explicitlyClosed.add(name);
283
827
  const session = sessions.get(name);
284
828
  if (session)
285
- await release(session);
829
+ await retireForCommand(session, parsed.command === 'kill-all');
286
830
  })));
287
831
  const errors = results.flatMap(result => result.status === 'rejected' ? [result.reason] : []);
832
+ try {
833
+ await options.persistence?.close?.(undefined, local.signal);
834
+ }
835
+ catch (error) {
836
+ errors.push(error);
837
+ }
288
838
  if (errors.length)
289
839
  throw new AggregateError(errors, 'Playwright close-all failed');
840
+ if (parsed.json)
841
+ await invocation.write(JSON.stringify({ closed }, null, 2) + '\n');
290
842
  check();
291
843
  return;
292
844
  }
293
845
  await enqueue(parsed.session, async () => {
294
- try {
846
+ let paused;
847
+ let commandFailure;
848
+ await (async () => {
295
849
  check();
850
+ if (options.persistence && ability.scope === 'session' && !['open', 'close', 'close-all', 'list', 'delete-data'].includes(parsed.command)
851
+ && !explicitlyClosed.has(parsed.session) && (!suppressUnknownRestores || sessions.has(parsed.session))
852
+ && (!sessions.has(parsed.session) || sessions.get(parsed.session).state === 'closed')) {
853
+ const restored = await options.persistence.restore({ name: parsed.session, signal: local.signal });
854
+ if (restored) {
855
+ let transferred = false;
856
+ try {
857
+ await restoreSession({ name: parsed.session, signal: local.signal,
858
+ ...(restored.expiresAt === undefined ? {} : { expiresAt: restored.expiresAt }),
859
+ ...(restored.idleTimeoutMs === undefined ? {} : { idleTimeoutMs: restored.idleTimeoutMs }),
860
+ ...(restored.contextOptions === undefined ? {} : { contextOptions: restored.contextOptions }),
861
+ ...(restored.configuration === undefined ? {} : { configuration: restored.configuration }),
862
+ acquire: async () => { transferred = true; return restored; } }, true);
863
+ }
864
+ catch (error) {
865
+ if (!transferred) {
866
+ try {
867
+ await restored.lease.release();
868
+ }
869
+ catch (cleanupError) {
870
+ throw new AggregateError([error, cleanupError], 'Playwright restoration and cleanup failed');
871
+ }
872
+ }
873
+ throw error;
874
+ }
875
+ }
876
+ check();
877
+ }
878
+ const current = sessions.get(parsed.session);
879
+ if (current?.state === 'open' && current.idleTimeoutMs) {
880
+ paused = current;
881
+ current.idlePaused = true;
882
+ clearTimeout(current.expiryTimer);
883
+ }
884
+ const modalPage = current?.page;
885
+ if (modalPage && getPlaywrightModal(modalPage) && !['dialog-accept', 'dialog-dismiss', 'upload', 'snapshot', 'close', 'delete-data', 'tab-select', 'tab-close'].includes(parsed.command)) {
886
+ retained = true;
887
+ throw new Error(`Tool "${parsed.command}" does not handle the modal state.`);
888
+ }
296
889
  if (ability.execute) {
297
890
  let browserSession;
298
891
  if (ability.scope === 'session') {
@@ -313,14 +906,43 @@ export function createPlaywrightController(options = {}) {
313
906
  browserSession = Object.freeze({
314
907
  context: session.lease.context,
315
908
  page: session.page,
316
- async resolveTarget(ref) { checkSession(session); const target = await session.snapshot.resolve(ref); checkSession(session); return target; },
909
+ ...(session.configuration ? { configuration: session.configuration } : {}),
910
+ resolveTarget(ref) { return resolveTarget(session, ref); },
911
+ targetLocator(target) {
912
+ const locator = targetLocators.get(target);
913
+ if (!locator)
914
+ throw new Error('Target must be resolved before describing its locator');
915
+ return locator;
916
+ },
917
+ replaceContext(state) { return replaceContext(session, state); },
918
+ async invalidateTargets() { checkSession(session); await session.snapshot.invalidate(); checkSession(session); },
919
+ runAction(action) { return runAction(session, action); },
920
+ ...(session.lease.executeCode ? { executeCode: ((executionOptions) => {
921
+ checkSession(session);
922
+ return session.lease.executeCode(executionOptions);
923
+ }) } : {}),
924
+ ...(session.lease.generateActionCode ? { generateActionCode: session.lease.generateActionCode } : {}),
925
+ ...(session.lease.captureTrace ? { captureTrace: session.lease.captureTrace } : {}),
926
+ ...(session.lease.prepareFileBytes ? { prepareFileBytes(bytes) {
927
+ checkSession(session);
928
+ if (!(bytes instanceof Uint8Array) || bytes.byteLength > maxArtifactBytes || bytes.byteLength > maxCommandBytes)
929
+ throw new PlaywrightResourceLimitError('Artifact byte limit exceeded');
930
+ return session.lease.prepareFileBytes(bytes);
931
+ } } : {}),
932
+ ...(session.lease.captureArtifact ? { captureArtifact: ((produce, captureOptions) => {
933
+ checkSession(session);
934
+ return session.lease.captureArtifact(produce, { ...captureOptions,
935
+ signal: AbortSignal.any([local.signal, captureOptions.signal]),
936
+ maxBytes: Math.min(captureOptions.maxBytes, maxArtifactBytes, maxCommandBytes),
937
+ });
938
+ }) } : {}),
317
939
  async selectPage(page) {
318
940
  checkSession(session);
319
941
  const pages = session.lease.context.pages();
320
942
  if (!pages.includes(page))
321
943
  throw new Error('Page does not belong to this session');
322
944
  if (pages.length > maxTabs)
323
- throw new Error('Playwright tab limit exceeded');
945
+ throw new PlaywrightResourceLimitError('Playwright tab limit exceeded');
324
946
  await selectPage(session, page, () => checkSession(session));
325
947
  session.pages = [...pages];
326
948
  },
@@ -332,33 +954,110 @@ export function createPlaywrightController(options = {}) {
332
954
  },
333
955
  });
334
956
  }
335
- const executeAbility = () => executePlaywrightAbility(ability, parsed, invocation, { signal: local.signal, maxCommandBytes, maxArtifactBytes, ...(browserSession ? { browserSession } : {}), check: () => active ? checkSession(active) : check() });
336
- if (active)
337
- await active.snapshot.withReferences(executeAbility);
338
- else
339
- await executeAbility();
957
+ const executeAbility = () => executePlaywrightAbility(ability, parsed, invocation, { signal: local.signal, maxCommandBytes, maxArtifactBytes, actionTimeoutMs: sessionActionTimeout(active), codeExecutionTimeoutMs, navigationTimeoutMs: sessionNavigationTimeout(active), maxPages: maxTabs, ...(browserSession ? { browserSession } : {}), check: () => active ? checkSession(active) : check() });
958
+ let result;
959
+ try {
960
+ result = active ? await active.snapshot.withReferences(executeAbility) : await executeAbility();
961
+ }
962
+ catch (error) {
963
+ if (canRetainAfterError(active, error))
964
+ retained = true;
965
+ throw error;
966
+ }
340
967
  check();
341
968
  if (active) {
969
+ if (!active.page || !getPlaywrightModal(active.page))
970
+ await Promise.all([...active.pendingActions ?? []]);
342
971
  await active.snapshot.invalidate();
343
972
  checkSession(active);
344
973
  active.pages = [...active.lease.context.pages()];
345
974
  if (active.pages.length > maxTabs)
346
- throw new Error('Playwright tab limit exceeded');
975
+ throw new PlaywrightResourceLimitError('Playwright tab limit exceeded');
976
+ await checkpoint(active, local.signal);
977
+ if (active.page && getPlaywrightModal(active.page))
978
+ result = { sections: [...result?.sections ?? [], ...(await pageResult(active, undefined)).sections] };
979
+ else {
980
+ const downloads = await downloadSections(active);
981
+ if (downloads.length)
982
+ result = { sections: [...result?.sections ?? [], ...downloads] };
983
+ }
984
+ const traceLinks = await flushTrace(active);
985
+ traceFlushed = true;
986
+ if (traceLinks.length && ['tracing-start', 'tracing-stop'].includes(parsed.command)) {
987
+ const sections = [...result?.sections ?? []];
988
+ const index = sections.findIndex(section => section.title === 'Result');
989
+ if (index === -1)
990
+ sections.push({ title: 'Result', content: traceLinks.join('\n') });
991
+ else
992
+ sections[index] = { ...sections[index], content: sections[index].content + '\n' + traceLinks.join('\n') };
993
+ result = { sections };
994
+ }
347
995
  }
996
+ if (result)
997
+ await writeResult(result);
348
998
  retained = true;
349
999
  return;
350
1000
  }
351
1001
  if (parsed.command === 'close') {
352
1002
  const session = sessions.get(parsed.session);
353
- if (session)
354
- await release(session);
1003
+ const wasOpen = session?.state === 'open' || (await savedSessions(local.signal)).some(saved => saved.name === parsed.session);
1004
+ explicitlyClosed.add(parsed.session);
1005
+ const errors = [];
1006
+ try {
1007
+ if (session)
1008
+ await retireForCommand(session);
1009
+ }
1010
+ catch (error) {
1011
+ errors.push(error);
1012
+ }
1013
+ try {
1014
+ await options.persistence?.close?.(parsed.session, local.signal);
1015
+ }
1016
+ catch (error) {
1017
+ errors.push(error);
1018
+ }
1019
+ if (errors.length === 1)
1020
+ throw errors[0];
1021
+ if (errors.length)
1022
+ throw new AggregateError(errors, 'Playwright close failed');
1023
+ await invocation.write(parsed.json ? JSON.stringify({ session: parsed.session, status: wasOpen ? 'closed' : 'not-open' }, null, 2) + '\n'
1024
+ : wasOpen ? `Browser '${parsed.session}' closed\n\n` : `Browser '${parsed.session}' is not open.\n`);
1025
+ check();
1026
+ return;
1027
+ }
1028
+ if (parsed.command === 'delete-data') {
1029
+ const session = sessions.get(parsed.session);
1030
+ explicitlyClosed.add(parsed.session);
1031
+ const errors = [];
1032
+ try {
1033
+ if (session && session.state !== 'closed')
1034
+ await release(session);
1035
+ }
1036
+ catch (error) {
1037
+ errors.push(error);
1038
+ }
1039
+ try {
1040
+ await options.persistence?.delete(parsed.session, local.signal);
1041
+ sessions.delete(parsed.session);
1042
+ }
1043
+ catch (error) {
1044
+ errors.push(error);
1045
+ }
1046
+ if (errors.length === 1)
1047
+ throw errors[0];
1048
+ if (errors.length)
1049
+ throw new AggregateError(errors, 'Playwright retirement and data deletion failed');
1050
+ await invocation.write(parsed.json ? JSON.stringify({ session: parsed.session, deleted: !!session || !!options.persistence }, null, 2) + '\n'
1051
+ : `Deleted user data for browser '${parsed.session}'.\n`);
355
1052
  check();
356
1053
  return;
357
1054
  }
358
1055
  if (parsed.command === 'open') {
1056
+ const openOptions = await resolvePlaywrightOpenOptions(parsed.options, invocation, options.adapter, Math.min(maxArtifactBytes, maxCommandBytes));
1057
+ explicitlyClosed.delete(parsed.session);
359
1058
  const previous = sessions.get(parsed.session);
360
1059
  if (previous && previous.state !== 'closed')
361
- throw new Error(`Session already ${previous.state}: ${parsed.session}`);
1060
+ await retireForCommand(previous);
362
1061
  // Remote-loss retirement must finish before reusing its capacity.
363
1062
  if (previous?.releasing)
364
1063
  await previous.releasing;
@@ -367,25 +1066,31 @@ export function createPlaywrightController(options = {}) {
367
1066
  // claim this slot during retirement's asynchronous boundary.
368
1067
  const occupied = [...sessions.values()].filter(s => s.state !== 'closed').length;
369
1068
  if (occupied >= maxSessions)
370
- throw new Error('Playwright session capacity exceeded');
371
- active = { name: parsed.session, generation: ++generation, state: 'acquiring', snapshot: createSnapshotEngine({ maxSnapshotBytes, maxSnapshotRefs }, () => `e${++refSequence}`), cleanups: new Set() };
1069
+ throw new PlaywrightResourceLimitError('Playwright session capacity exceeded');
1070
+ active = { name: parsed.session, generation: ++generation, state: 'acquiring', snapshot: createSnapshotEngine({ maxSnapshotBytes, maxSnapshotRefs }, () => `e${++refSequence}`), cleanups: new Set(), idleTimeoutMs: openOptions.idleTimeoutMs, contextOptions: openOptions.contextOptions };
1071
+ active.configuration = { ...openOptions.configuration, browserName: openOptions.browser, headless: openOptions.headless };
372
1072
  sessions.set(parsed.session, active);
373
1073
  const session = active;
374
- session.lease = await options.adapter.acquire({ acquisitionId: `playwright-${session.generation}`, session: session.name, browser: parsed.browser, headless: parsed.headless, signal: local.signal });
1074
+ session.lease = await options.adapter.acquire({ acquisitionId: `playwright-${session.generation}`, session: session.name, browser: openOptions.browser, headless: openOptions.headless, contextOptions: openOptions.contextOptions, signal: local.signal });
375
1075
  check();
376
1076
  observeSession(session);
1077
+ await initializeContext(session);
377
1078
  checkSession(session);
378
1079
  if (session.lease.context.pages().length >= maxTabs)
379
- throw new Error('Playwright tab limit exceeded');
1080
+ throw new PlaywrightResourceLimitError('Playwright tab limit exceeded');
380
1081
  await selectPage(session, await session.lease.context.newPage(), () => checkSession(session));
381
1082
  session.pages = [...session.lease.context.pages()];
382
1083
  checkSession(session);
383
1084
  if (parsed.url)
384
- await session.page.goto(parsed.url, { timeout: actionTimeoutMs });
1085
+ await runAction(session, async () => { await session.page.goto(parsed.url, { timeout: sessionNavigationTimeout(session) }); });
385
1086
  checkSession(session);
386
- await invocation.write(`Session ${session.name} open\n`);
1087
+ const result = await pageResult(session, actionCode(session, { name: 'navigate', url: parsed.url ?? 'about:blank' }, `await page.goto(${playwrightCodeString(parsed.url ?? 'about:blank')});`), 'file');
1088
+ const rendered = serializePlaywrightResult(configuredResult(result), parsed);
1089
+ await invocation.write(parsed.json ? JSON.stringify({ session: session.name, result: JSON.parse(rendered) }, null, 2) + '\n'
1090
+ : `### Browser \`${session.name}\` opened.\n` + rendered);
387
1091
  checkSession(session);
388
1092
  session.state = 'open';
1093
+ await checkpoint(session, local.signal);
389
1094
  retained = true;
390
1095
  }
391
1096
  else {
@@ -400,10 +1105,11 @@ export function createPlaywrightController(options = {}) {
400
1105
  }
401
1106
  await session.snapshot.invalidate();
402
1107
  checkSession(session);
403
- await session.page.goto(parsed.url, { timeout: actionTimeoutMs });
1108
+ await runAction(session, async () => { await session.page.goto(parsed.url, { timeout: sessionNavigationTimeout(session) }); });
404
1109
  checkSession(session);
405
- await invocation.write(`Session ${session.name} navigated\n`);
1110
+ await writeResult(await pageResult(session, actionCode(session, { name: 'navigate', url: parsed.url }, `await page.goto(${playwrightCodeString(parsed.url)});`), 'file'));
406
1111
  checkSession(session);
1112
+ await checkpoint(session, local.signal);
407
1113
  retained = true;
408
1114
  return;
409
1115
  }
@@ -419,12 +1125,31 @@ export function createPlaywrightController(options = {}) {
419
1125
  throw new Error('Selected tab closed; select a tab explicitly');
420
1126
  }
421
1127
  checkSession(session);
422
- if (parsed.command === 'tab-list') {
423
- await invocation.write(pages.map((tab, index) => `${index}\t${tab === page ? 'selected' : 'open'}\t${JSON.stringify(tab.url())}\n`).join(''));
1128
+ if (parsed.command === 'config-print') {
1129
+ const config = { browser: {
1130
+ ...(session.configuration?.browserName ? { browserName: session.configuration.browserName } : {}),
1131
+ isolated: true,
1132
+ launchOptions: session.configuration?.headless === undefined ? {} : { headless: session.configuration.headless },
1133
+ contextOptions: session.contextOptions ?? {},
1134
+ ...(session.configuration?.initScriptFiles ? { initScript: session.configuration.initScriptFiles } : {}),
1135
+ ...(session.configuration?.initPages ? { initPage: session.configuration.initPages.map(page => page.filename) } : {}),
1136
+ }, codegen: session.configuration?.codegen ?? 'typescript',
1137
+ timeouts: { action: sessionActionTimeout(session), navigation: sessionNavigationTimeout(session), expect: session.configuration?.timeouts?.expect ?? 5000, settle: session.configuration?.timeouts?.settle ?? 500, idle: session.idleTimeoutMs ?? 0 },
1138
+ snapshot: { mode: 'full', ...session.configuration?.snapshot }, skillMode: true,
1139
+ ...Object.fromEntries(['network', 'console', 'outputDir', 'outputMaxSize', 'testIdAttribute', 'configFile'].flatMap(key => {
1140
+ const value = session.configuration?.[key];
1141
+ return value === undefined ? [] : [[key, value]];
1142
+ })),
1143
+ };
1144
+ await writeResult({ sections: [{ title: 'Result', content: { json: config } }] });
1145
+ }
1146
+ else if (parsed.command === 'tab-list') {
1147
+ const tabs = await Promise.all(pages.map(async (tab, index) => `- ${index}:${tab === page ? ' (current)' : ''} [${await tab.title?.() ?? ''}](${tab.url()})`));
1148
+ await writeResult({ sections: [{ title: 'Result', content: tabs.join('\n') }] });
424
1149
  }
425
1150
  else if (parsed.command === 'tab-new') {
426
1151
  if (pages.length >= maxTabs)
427
- throw new Error('Playwright tab limit exceeded');
1152
+ throw new PlaywrightResourceLimitError('Playwright tab limit exceeded');
428
1153
  retained = false;
429
1154
  await session.snapshot.invalidate();
430
1155
  checkSession(session);
@@ -432,9 +1157,9 @@ export function createPlaywrightController(options = {}) {
432
1157
  await selectPage(session, tab, () => checkSession(session));
433
1158
  session.pages = [...session.lease.context.pages()];
434
1159
  if (parsed.url)
435
- await tab.goto(parsed.url, { timeout: actionTimeoutMs });
1160
+ await runAction(session, async () => { await tab.goto(parsed.url, { timeout: sessionNavigationTimeout(session) }); });
436
1161
  checkSession(session);
437
- await invocation.write(`Tab ${session.pages.indexOf(tab)} selected\n`);
1162
+ await writeResult(await pageResult(session, 'await page.context().newPage();', 'file'));
438
1163
  }
439
1164
  else if (parsed.command === 'tab-select' || parsed.command === 'tab-close') {
440
1165
  const index = parsed.tab ?? pages.indexOf(page);
@@ -459,58 +1184,160 @@ export function createPlaywrightController(options = {}) {
459
1184
  else
460
1185
  await selectPage(session, tab, () => checkSession(session));
461
1186
  checkSession(session);
462
- await invocation.write(`Tab ${index} ${parsed.command === 'tab-close' ? 'closed' : 'selected'}\n`);
1187
+ await writeResult(await pageResult(session, parsed.command === 'tab-close' ? `await page.context().pages()[${index}].close();` : undefined, 'file'));
1188
+ }
1189
+ else if (parsed.command === 'highlight') {
1190
+ const target = parsed.args[0];
1191
+ const hide = parsed.options.hide === true;
1192
+ if (!target && !hide)
1193
+ throw new Error('Highlight requires a target unless --hide is used');
1194
+ const style = parsed.options.style;
1195
+ if (!target) {
1196
+ if (page.hideHighlight)
1197
+ await page.hideHighlight();
1198
+ else
1199
+ await updatePlaywrightHighlight(page, undefined, { hide });
1200
+ await writeResult({ sections: [{ title: 'Result', content: 'Hid page highlight' }] });
1201
+ }
1202
+ else {
1203
+ const handle = await resolveTarget(session, target);
1204
+ const isReference = isPlaywrightSnapshotRef(target);
1205
+ const selector = isReference ? session.snapshot.nativeSelector(target) : target;
1206
+ if (!selector)
1207
+ throw new Error('Native locator generation unavailable for this snapshot');
1208
+ const description = await generatedPlaywrightLocator(page, selector, isReference);
1209
+ const locator = page.locator(isReference ? selector : playwrightLocatorSelector(page, selector));
1210
+ if (page.hideHighlight && locator.hideHighlight && locator.highlight) {
1211
+ if (hide)
1212
+ await locator.hideHighlight();
1213
+ else
1214
+ await locator.highlight(style === undefined ? {} : { style });
1215
+ }
1216
+ else
1217
+ await updatePlaywrightHighlight(page, handle, { hide, ...(style === undefined ? {} : { style }) });
1218
+ await writeResult({ sections: [{ title: 'Result', content: hide ? `Hid highlight for ${description}` : `Highlighted ${description}` }] });
1219
+ }
1220
+ }
1221
+ else if (parsed.command === 'generate-locator') {
1222
+ const target = parsed.args[0];
1223
+ await resolveTarget(session, target);
1224
+ const isReference = isPlaywrightSnapshotRef(target);
1225
+ const selector = isReference ? session.snapshot.nativeSelector(target) : target;
1226
+ if (!selector)
1227
+ throw new Error('Native locator generation unavailable for this snapshot');
1228
+ const result = await generatedPlaywrightLocator(page, selector, isReference);
1229
+ checkSession(session);
1230
+ await writeResult({ sections: [{ title: 'Result', content: result }] });
1231
+ }
1232
+ else if (parsed.command === 'find') {
1233
+ const text = await session.snapshot.capture(page, local.signal);
1234
+ const result = await findPlaywrightSnapshot(text, { page: page, ...(parsed.args[0] === undefined ? {} : { text: parsed.args[0] }),
1235
+ ...(parsed.options.regex === undefined ? {} : { regex: parsed.options.regex }),
1236
+ });
1237
+ checkSession(session);
1238
+ await writeResult({ sections: [{ title: 'Result', content: result }] });
463
1239
  }
464
1240
  else if (parsed.command === 'snapshot') {
465
1241
  if (typeof page.on !== 'function' || typeof page.off !== 'function')
466
1242
  throw new Error('Snapshot engine unsupported: navigation events required');
467
- const text = await session.snapshot.capture(page, local.signal);
468
- checkSession(session);
469
- if (parsed.filename !== undefined) {
470
- const bytes = new TextEncoder().encode(text);
471
- if (bytes.byteLength > maxArtifactBytes)
472
- throw new Error('Artifact byte limit exceeded');
473
- await invocation.writeArtifact(bytes, parsed.filename);
474
- }
475
- else
476
- await invocation.write(text);
1243
+ await session.snapshot.withReferences(async () => { await writeResult(await pageResult(session, undefined, 'inline', parsed.filename)); });
477
1244
  }
478
1245
  else if (parsed.command === 'click' || parsed.command === 'fill') {
479
1246
  const command = parsed.command;
480
1247
  await session.snapshot.withReferences(async () => {
481
- const target = await session.snapshot.resolve(parsed.ref);
1248
+ const target = await resolveTarget(session, parsed.ref);
482
1249
  if (typeof target[command] !== 'function')
483
1250
  throw new Error('Snapshot action unsupported');
484
1251
  checkSession(session);
485
1252
  retained = false;
486
1253
  if (command === 'click')
487
- await target.click({ timeout: actionTimeoutMs });
488
- else
489
- await target.fill(parsed.value, { timeout: actionTimeoutMs });
1254
+ await runAction(session, () => target.click({ timeout: sessionActionTimeout(session), ...(parsed.button === 'left' ? {} : { button: parsed.button }), ...(parsed.modifiers?.length ? { modifiers: parsed.modifiers } : {}) }));
1255
+ else {
1256
+ await runAction(session, async () => {
1257
+ await target.fill(parsed.value, { timeout: sessionActionTimeout(session) });
1258
+ if (parsed.options.submit)
1259
+ await page.keyboard.press('Enter');
1260
+ });
1261
+ }
490
1262
  });
491
1263
  }
492
1264
  else if (parsed.command === 'press') {
493
1265
  if (typeof page.keyboard?.press !== 'function')
494
1266
  throw new Error('Keyboard press unsupported');
495
1267
  retained = false;
496
- await page.keyboard.press(parsed.value);
1268
+ await runAction(session, () => page.keyboard.press(parsed.value));
497
1269
  }
498
1270
  else if (parsed.command === 'screenshot') {
499
- const bytes = await capturePlaywrightScreenshot(page, { type: parsed.imageType, fullPage: parsed.fullPage, timeout: actionTimeoutMs, maxArtifactBytes, signal: local.signal });
1271
+ const bytes = parsed.ref
1272
+ ? await capturePlaywrightTargetScreenshot(await resolveTarget(session, parsed.ref), { type: parsed.imageType, scale: parsed.scale, timeout: sessionActionTimeout(session), maxArtifactBytes, signal: local.signal })
1273
+ : await capturePlaywrightScreenshot(page, { type: parsed.imageType, fullPage: parsed.fullPage, scale: parsed.scale, timeout: sessionActionTimeout(session), maxArtifactBytes, signal: local.signal });
500
1274
  checkSession(session);
501
1275
  // Uint8Array constructor copies even when bytes is a Buffer view.
502
- await invocation.writeArtifact(new Uint8Array(bytes), parsed.filename);
1276
+ const filename = parsed.filename ?? capabilityArtifactName(parsed.ref ? 'element' : 'page', parsed.imageType, session.configuration);
1277
+ await invocation.writeArtifact(new Uint8Array(bytes), filename);
1278
+ const label = parsed.ref ? 'element' : parsed.fullPage ? 'full page' : 'viewport';
1279
+ const screenshotTarget = parsed.ref ? targetLocators.get(parsed.ref) : 'page';
1280
+ await writeResult({ sections: [
1281
+ { title: 'Result', content: `- [Screenshot of ${label}](${filename})` },
1282
+ { title: 'Ran Playwright code', content: `await ${screenshotTarget}.screenshot({ path: ${playwrightCodeString(filename)}, type: ${playwrightCodeString(parsed.imageType)},${parsed.imageType === 'jpeg' ? ' quality: 90,' : ''} scale: ${playwrightCodeString(parsed.scale)}${parsed.ref ? '' : `, fullPage: ${parsed.fullPage}`} });`, codeframe: 'js' },
1283
+ ] });
503
1284
  }
504
1285
  checkSession(session);
1286
+ if (parsed.command === 'click' || parsed.command === 'fill' || parsed.command === 'press') {
1287
+ const clickOptions = { ...(parsed.button && parsed.button !== 'left' ? { button: parsed.button } : {}), ...(parsed.modifiers?.length ? { modifiers: parsed.modifiers } : {}) };
1288
+ const args = parsed.command === 'fill' ? playwrightCodeString(parsed.value) : Object.keys(clickOptions).length ? JSON.stringify(clickOptions) : '';
1289
+ let code = parsed.command === 'press' ? `await page.keyboard.press(${playwrightCodeString(parsed.value)});`
1290
+ : `await ${targetLocators.get(parsed.ref)}.${parsed.command}(${args});${parsed.command === 'fill' && parsed.options.submit ? `\nawait page.keyboard.press('Enter');` : ''}`;
1291
+ if (parsed.command !== 'press' && session.lease?.generateActionCode) {
1292
+ const selector = playwrightLocatorSelector(page, targetLocators.get(parsed.ref).slice('page.'.length));
1293
+ if (parsed.command === 'click') {
1294
+ const modifiers = parsed.modifiers ?? [];
1295
+ const mask = (modifiers.includes('Alt') ? 1 : 0) | (modifiers.includes('Control') || modifiers.includes('ControlOrMeta') ? 2 : 0) | (modifiers.includes('Meta') ? 4 : 0) | (modifiers.includes('Shift') ? 8 : 0);
1296
+ code = actionCode(session, { name: 'click', selector, button: parsed.button ?? 'left', modifiers: mask, clickCount: 1 }, code);
1297
+ }
1298
+ else {
1299
+ const base = `await ${targetLocators.get(parsed.ref)}.fill(${args});`;
1300
+ code = actionCode(session, { name: 'fill', selector, text: parsed.value }, base);
1301
+ if (parsed.options.submit)
1302
+ code += '\n' + actionCode(session, { name: 'press', selector, key: 'Enter', modifiers: 0 }, "await page.keyboard.press('Enter');");
1303
+ }
1304
+ }
1305
+ await writeResult(await pageResult(session, code, 'file'));
1306
+ }
1307
+ await checkpoint(session, local.signal);
505
1308
  retained = true;
506
1309
  }
1310
+ })().catch(error => {
1311
+ commandFailure = { error };
1312
+ });
1313
+ let traceFailure;
1314
+ try {
1315
+ if (active?.state === 'open' && retained && !traceFlushed)
1316
+ await flushTrace(active);
507
1317
  }
508
- finally {
509
- // Retirement is part of the session queue. Keep its rejection for
510
- // shared invocation cleanup so both execution and cleanup causes survive.
511
- if (active && !retained)
512
- await release(active).catch(() => { });
1318
+ catch (error) {
1319
+ retained = false;
1320
+ traceFailure = { error };
1321
+ }
1322
+ if (paused) {
1323
+ delete paused.idlePaused;
1324
+ if (paused.state === 'open') {
1325
+ paused.expiresAt = Date.now() + paused.idleTimeoutMs;
1326
+ scheduleExpiry(paused);
1327
+ }
1328
+ }
1329
+ // Retirement is part of the session queue. Keep its rejection for
1330
+ // shared invocation cleanup so both execution and cleanup causes survive.
1331
+ if (active && !retained)
1332
+ await release(active).catch(() => { });
1333
+ if (traceFailure) {
1334
+ if (commandFailure)
1335
+ throw new AggregateError([commandFailure.error, traceFailure.error], 'Playwright command and trace flush failed');
1336
+ throw traceFailure.error;
513
1337
  }
1338
+ if (commandFailure)
1339
+ throw commandFailure.error;
1340
+ await enforceOutputBudget();
514
1341
  });
515
1342
  };
516
1343
  const operation = execute();
@@ -546,9 +1373,17 @@ export function createPlaywrightController(options = {}) {
546
1373
  return disposal;
547
1374
  // Install shared completion before notifying potentially reentrant hosts.
548
1375
  disposal = Promise.resolve().then(async () => {
549
- const retirements = [...sessions.values()].filter(s => s.lease).map(release);
1376
+ const retirements = [...sessions.values()].filter(s => s.lease).map(async (session) => {
1377
+ try {
1378
+ await checkpoint(session, new AbortController().signal, false);
1379
+ }
1380
+ finally {
1381
+ await release(session);
1382
+ }
1383
+ });
550
1384
  await Promise.allSettled([...work]);
551
- const results = await Promise.allSettled([...retirements, ...[...sessions.values()].map(release)]);
1385
+ const results = await Promise.allSettled(retirements);
1386
+ results.push(...await Promise.allSettled([...sessions.values()].map(release)));
552
1387
  const errors = [...new Set(results.flatMap(result => result.status === 'rejected' ? [result.reason] : []))];
553
1388
  if (errors.length)
554
1389
  throw new AggregateError(errors, 'Playwright disposal failed');
@@ -556,6 +1391,7 @@ export function createPlaywrightController(options = {}) {
556
1391
  lifetime.abort(new Error('Playwright controller is disposed'));
557
1392
  return disposal;
558
1393
  };
559
- return { run, dispose, restoreSession, inspectSessions };
1394
+ return { run, dispose, restoreSession: (request) => restoreSession(request), inspectSessions };
560
1395
  }
1396
+ import { PlaywrightResourceLimitError, isPlaywrightResourceLimitError } from './resource-limit.js';
561
1397
  //# sourceMappingURL=controller.js.map