feat: implement screen recording via chrome.tabCapture with offscreen document

- Use chrome.tabCapture.getMediaStreamId() from service worker
- Pass stream ID to offscreen document for MediaRecorder
- Recording survives page navigation (unlike getDisplayMedia)
- Requires user to click extension icon first (activeTab permission)
- Add esbuild to build offscreen.ts separately
- Update prompt docs with recording instructions
This commit is contained in:
Tommy D. Rossi
2026-01-23 16:57:51 +01:00
parent e56e27e5e5
commit 74497c2a1a
8 changed files with 210 additions and 278 deletions
+1
View File
@@ -24,6 +24,7 @@
"devDependencies": { "devDependencies": {
"@types/chrome": "^0.0.315", "@types/chrome": "^0.0.315",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"esbuild": "^0.27.2",
"typescript": "^5.8.2", "typescript": "^5.8.2",
"vite": "^5.4.21", "vite": "^5.4.21",
"vite-plugin-static-copy": "^3.1.1" "vite-plugin-static-copy": "^3.1.1"
+145 -119
View File
@@ -12,6 +12,42 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms)) return new Promise((resolve) => setTimeout(resolve, ms))
} }
// ============= Offscreen Document Management =============
// Offscreen documents are needed for screen recording because tabCapture.capture()
// is not available in MV3 service workers. Instead we use tabCapture.getMediaStreamId()
// and pass the stream ID to an offscreen document that can use getUserMedia().
let offscreenDocumentCreating: Promise<void> | null = null
async function ensureOffscreenDocument(): Promise<void> {
// Check if already exists
const existingContexts = await chrome.runtime.getContexts({
contextTypes: [chrome.runtime.ContextType.OFFSCREEN_DOCUMENT],
documentUrls: [chrome.runtime.getURL('offscreen.html')],
})
if (existingContexts.length > 0) {
return
}
// Reuse in-progress creation
if (offscreenDocumentCreating) {
return offscreenDocumentCreating
}
offscreenDocumentCreating = chrome.offscreen.createDocument({
url: 'offscreen.html',
reasons: [chrome.offscreen.Reason.USER_MEDIA],
justification: 'Screen recording via chrome.tabCapture',
})
try {
await offscreenDocumentCreating
} finally {
offscreenDocumentCreating = null
}
}
let childSessions: Map<string, { tabId: number; targetId?: string }> = new Map() let childSessions: Map<string, { tabId: number; targetId?: string }> = new Map()
let nextSessionId = 1 let nextSessionId = 1
let tabGroupQueue: Promise<void> = Promise.resolve() let tabGroupQueue: Promise<void> = Promise.resolve()
@@ -204,7 +240,7 @@ class ConnectionManager {
if (message.method === 'isRecording') { if (message.method === 'isRecording') {
try { try {
const result = handleIsRecording(message.params) const result = await handleIsRecording(message.params)
sendMessage({ id: message.id, result }) sendMessage({ id: message.id, result })
} catch (error: any) { } catch (error: any) {
logger.error('Failed to check recording status:', error) logger.error('Failed to check recording status:', error)
@@ -215,7 +251,7 @@ class ConnectionManager {
if (message.method === 'cancelRecording') { if (message.method === 'cancelRecording') {
try { try {
const result = handleCancelRecording(message.params) const result = await handleCancelRecording(message.params)
sendMessage({ id: message.id, result }) sendMessage({ id: message.id, result })
} catch (error: any) { } catch (error: any) {
logger.error('Failed to cancel recording:', error) logger.error('Failed to cancel recording:', error)
@@ -1079,55 +1115,43 @@ async function handleStartRecording(params: StartRecordingParams): Promise<{ suc
logger.debug('Starting recording for tab:', tabId, 'params:', params) logger.debug('Starting recording for tab:', tabId, 'params:', params)
try { try {
// Use chrome.tabCapture to capture the tab // Ensure offscreen document exists
const stream = await new Promise<MediaStream>((resolve, reject) => { await ensureOffscreenDocument()
chrome.tabCapture.capture(
{ // Get stream ID using chrome.tabCapture.getMediaStreamId (works without user gesture)
audio: params.audio ?? false, const streamId = await new Promise<string>((resolve, reject) => {
video: true, chrome.tabCapture.getMediaStreamId({ targetTabId: tabId }, (id) => {
videoConstraints: { if (chrome.runtime.lastError) {
mandatory: { reject(new Error(chrome.runtime.lastError.message))
minFrameRate: params.frameRate ?? 30, } else if (!id) {
maxFrameRate: params.frameRate ?? 30, reject(new Error('Failed to get media stream ID'))
}, } else {
}, resolve(id)
},
(capturedStream) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message))
} else if (!capturedStream) {
reject(new Error('Failed to capture tab - no stream returned'))
} else {
resolve(capturedStream)
}
} }
) })
}) })
// Determine best codec logger.debug('Got stream ID for tab:', tabId, 'streamId:', streamId.substring(0, 20) + '...')
const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9,opus')
? 'video/webm;codecs=vp9,opus'
: MediaRecorder.isTypeSupported('video/webm;codecs=vp8,opus')
? 'video/webm;codecs=vp8,opus'
: 'video/webm'
const recorder = new MediaRecorder(stream, { // Send message to offscreen document to start recording
mimeType, const result = await chrome.runtime.sendMessage({
action: 'startRecording',
tabId,
streamId,
frameRate: params.frameRate ?? 30,
videoBitsPerSecond: params.videoBitsPerSecond ?? 2500000, videoBitsPerSecond: params.videoBitsPerSecond ?? 2500000,
audioBitsPerSecond: params.audioBitsPerSecond ?? 128000, audioBitsPerSecond: params.audioBitsPerSecond ?? 128000,
}) audio: params.audio ?? false,
}) as { success: boolean; tabId?: number; startedAt?: number; mimeType?: string; error?: string }
const startedAt = Date.now() if (!result.success) {
return { success: false, error: result.error || 'Failed to start recording in offscreen document' }
}
const startedAt = result.startedAt || Date.now()
// Store recording info // Store recording info
const recordingInfo: RecordingInfo = { activeRecordings.set(tabId, { tabId, startedAt })
tabId,
startedAt,
recorder,
stream,
mimeType,
}
activeRecordings.set(tabId, recordingInfo)
// Update tab state // Update tab state
store.setState((state) => { store.setState((state) => {
@@ -1139,38 +1163,7 @@ async function handleStartRecording(params: StartRecordingParams): Promise<{ suc
return { tabs: newTabs } return { tabs: newTabs }
}) })
// Send binary chunks as they become available logger.debug('Recording started for tab:', tabId, 'mimeType:', result.mimeType)
recorder.ondataavailable = async (event) => {
if (event.data.size > 0 && connectionManager.ws?.readyState === WebSocket.OPEN) {
try {
// Send metadata message first
sendMessage({
method: 'recordingData',
params: { tabId },
})
// Then send binary data
const arrayBuffer = await event.data.arrayBuffer()
connectionManager.ws.send(arrayBuffer)
} catch (error: any) {
logger.error('Failed to send recording chunk:', error.message)
}
}
}
recorder.onerror = (event: any) => {
logger.error('MediaRecorder error:', event.error?.message || 'Unknown error')
handleCancelRecording({ sessionId: params.sessionId })
}
recorder.onstop = () => {
logger.debug('MediaRecorder stopped for tab:', tabId)
// Stream tracks are stopped in handleStopRecording
}
// Start recording with 1 second chunks
recorder.start(1000)
logger.debug('Recording started for tab:', tabId, 'mimeType:', mimeType)
return { success: true, tabId, startedAt } return { success: true, tabId, startedAt }
} catch (error: any) { } catch (error: any) {
logger.error('Failed to start recording:', error) logger.error('Failed to start recording:', error)
@@ -1196,37 +1189,16 @@ async function handleStopRecording(params: StopRecordingParams): Promise<{ succe
logger.debug('Stopping recording for tab:', tabId) logger.debug('Stopping recording for tab:', tabId)
try { try {
const { recorder, stream, startedAt } = recording // Send message to offscreen document to stop recording
const result = await chrome.runtime.sendMessage({
action: 'stopRecording',
}) as { success: boolean; tabId?: number; duration?: number; error?: string }
// Wait for recorder to finish if (!result.success) {
await new Promise<void>((resolve) => { return { success: false, error: result.error || 'Failed to stop recording in offscreen document' }
const originalOnStop = recorder.onstop
recorder.onstop = (event: Event) => {
if (originalOnStop) {
originalOnStop.call(recorder, event)
}
resolve()
}
if (recorder.state !== 'inactive') {
recorder.stop()
} else {
resolve()
}
})
// Stop all tracks
stream.getTracks().forEach((track: MediaStreamTrack) => track.stop())
// Send final marker
if (connectionManager.ws?.readyState === WebSocket.OPEN) {
sendMessage({
method: 'recordingData',
params: { tabId, final: true },
})
} }
const duration = Date.now() - startedAt const duration = result.duration || (Date.now() - recording.startedAt)
// Clean up // Clean up
activeRecordings.delete(tabId) activeRecordings.delete(tabId)
@@ -1251,7 +1223,7 @@ interface IsRecordingParams {
sessionId?: string sessionId?: string
} }
function handleIsRecording(params: IsRecordingParams): { isRecording: boolean; tabId?: number; startedAt?: number } { async function handleIsRecording(params: IsRecordingParams): Promise<{ isRecording: boolean; tabId?: number; startedAt?: number }> {
const tabId = resolveTabIdFromSessionId(params.sessionId) const tabId = resolveTabIdFromSessionId(params.sessionId)
if (!tabId) { if (!tabId) {
return { isRecording: false } return { isRecording: false }
@@ -1262,10 +1234,20 @@ function handleIsRecording(params: IsRecordingParams): { isRecording: boolean; t
return { isRecording: false, tabId } return { isRecording: false, tabId }
} }
return { // Check with offscreen document for actual recording state
isRecording: recording.recorder.state === 'recording', try {
tabId, const result = await chrome.runtime.sendMessage({
startedAt: recording.startedAt, action: 'isRecording',
}) as { isRecording: boolean; tabId?: number; startedAt?: number }
return {
isRecording: result.isRecording,
tabId,
startedAt: recording.startedAt,
}
} catch {
// If offscreen doc is gone, recording is not active
return { isRecording: false, tabId }
} }
} }
@@ -1273,7 +1255,7 @@ interface CancelRecordingParams {
sessionId?: string sessionId?: string
} }
function handleCancelRecording(params: CancelRecordingParams): { success: boolean; error?: string } { async function handleCancelRecording(params: CancelRecordingParams): Promise<{ success: boolean; error?: string }> {
const tabId = resolveTabIdFromSessionId(params.sessionId) const tabId = resolveTabIdFromSessionId(params.sessionId)
if (!tabId) { if (!tabId) {
return { success: false, error: 'No connected tab found' } return { success: false, error: 'No connected tab found' }
@@ -1287,12 +1269,10 @@ function handleCancelRecording(params: CancelRecordingParams): { success: boolea
logger.debug('Cancelling recording for tab:', tabId) logger.debug('Cancelling recording for tab:', tabId)
try { try {
const { recorder, stream } = recording // Send message to offscreen document to cancel recording
await chrome.runtime.sendMessage({
if (recorder.state !== 'inactive') { action: 'cancelRecording',
recorder.stop() })
}
stream.getTracks().forEach((track: MediaStreamTrack) => track.stop())
activeRecordings.delete(tabId) activeRecordings.delete(tabId)
store.setState((state) => { store.setState((state) => {
@@ -1320,15 +1300,13 @@ function handleCancelRecording(params: CancelRecordingParams): { success: boolea
} }
// Clean up recordings when tab is disconnected // Clean up recordings when tab is disconnected
function cleanupRecordingForTab(tabId: number): void { async function cleanupRecordingForTab(tabId: number): Promise<void> {
const recording = activeRecordings.get(tabId) const recording = activeRecordings.get(tabId)
if (recording) { if (recording) {
logger.debug('Cleaning up recording for disconnected tab:', tabId) logger.debug('Cleaning up recording for disconnected tab:', tabId)
try { try {
if (recording.recorder.state !== 'inactive') { // Tell offscreen document to cancel recording
recording.recorder.stop() await chrome.runtime.sendMessage({ action: 'cancelRecording' })
}
recording.stream.getTracks().forEach((track: MediaStreamTrack) => track.stop())
} catch (e) { } catch (e) {
logger.debug('Error cleaning up recording:', e) logger.debug('Error cleaning up recording:', e)
} }
@@ -1729,3 +1707,51 @@ chrome.contextMenus.onClicked.addListener(async (info, tab) => {
// Sync icons on first load // Sync icons on first load
void updateIcons() void updateIcons()
// Handle messages from offscreen document (recording chunks)
chrome.runtime.onMessage.addListener((message, _sender, _sendResponse) => {
if (message.action === 'recordingChunk') {
const { tabId, data, final } = message
if (connectionManager.ws?.readyState === WebSocket.OPEN) {
// Send metadata message first
sendMessage({
method: 'recordingData',
params: { tabId, final },
})
// Then send binary data if not final
if (data && !final) {
const buffer = new Uint8Array(data)
connectionManager.ws.send(buffer)
}
}
return false // Sync response, no need to keep channel open
}
if (message.action === 'recordingCancelled') {
const { tabId } = message
activeRecordings.delete(tabId)
store.setState((state) => {
const newTabs = new Map(state.tabs)
const existing = newTabs.get(tabId)
if (existing) {
newTabs.set(tabId, { ...existing, isRecording: false })
}
return { tabs: newTabs }
})
if (connectionManager.ws?.readyState === WebSocket.OPEN) {
sendMessage({
method: 'recordingCancelled',
params: { tabId },
})
}
return false
}
return false
})
+9 -9
View File
@@ -5,14 +5,14 @@
* - Future: audio processing, canvas operations, etc. * - Future: audio processing, canvas operations, etc.
*/ */
interface RecordingState { interface OffscreenRecordingState {
recorder: MediaRecorder | null recorder: MediaRecorder
stream: MediaStream | null stream: MediaStream
startedAt: number startedAt: number
tabId: number tabId: number
} }
let recording: RecordingState | null = null let recording: OffscreenRecordingState | null = null
// Message types // Message types
type StartRecordingMessage = { type StartRecordingMessage = {
@@ -119,8 +119,8 @@ async function handleStartRecording(params: StartRecordingMessage): Promise<any>
} }
} }
recorder.onerror = (event: any) => { recorder.onerror = (event: Event) => {
console.error('MediaRecorder error:', event.error) console.error('MediaRecorder error:', (event as ErrorEvent).error)
handleCancelRecording() handleCancelRecording()
} }
@@ -149,7 +149,7 @@ async function handleStopRecording(): Promise<any> {
// Stop recorder and wait for final data // Stop recorder and wait for final data
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
const originalOnStop = recorder.onstop const originalOnStop = recorder.onstop
recorder.onstop = (event) => { recorder.onstop = (event: Event) => {
if (originalOnStop) { if (originalOnStop) {
originalOnStop.call(recorder, event) originalOnStop.call(recorder, event)
} }
@@ -163,7 +163,7 @@ async function handleStopRecording(): Promise<any> {
}) })
// Stop all tracks // Stop all tracks
stream.getTracks().forEach((track) => track.stop()) stream.getTracks().forEach((track: MediaStreamTrack) => { track.stop() })
const duration = Date.now() - startedAt const duration = Date.now() - startedAt
@@ -205,7 +205,7 @@ function handleCancelRecording(): any {
if (recorder.state !== 'inactive') { if (recorder.state !== 'inactive') {
recorder.stop() recorder.stop()
} }
stream.getTracks().forEach((track) => track.stop()) stream.getTracks().forEach((track: MediaStreamTrack) => { track.stop() })
chrome.runtime.sendMessage({ chrome.runtime.sendMessage({
action: 'recordingCancelled', action: 'recordingCancelled',
+2 -5
View File
@@ -19,13 +19,10 @@ export interface ExtensionState {
} }
/** /**
* Recording state - kept separate from store since MediaRecorder/MediaStream can't be serialized. * Recording state - stored in service worker to track active recordings.
* Note: MediaRecorder and MediaStream types are available in the extension's browser context. * The actual MediaRecorder/MediaStream live in the offscreen document.
*/ */
export interface RecordingInfo { export interface RecordingInfo {
tabId: number tabId: number
startedAt: number startedAt: number
recorder: any // MediaRecorder - using any to avoid DOM type dependency
stream: any // MediaStream - using any to avoid DOM type dependency
mimeType: string
} }
+8 -2
View File
@@ -2,7 +2,6 @@ import { fileURLToPath } from 'url';
import { dirname, resolve } from 'path'; import { dirname, resolve } from 'path';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
import { viteStaticCopy } from 'vite-plugin-static-copy'; import { viteStaticCopy } from 'vite-plugin-static-copy';
import fs from 'node:fs';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
@@ -56,9 +55,16 @@ export default defineConfig({
input: { input: {
background: resolve(__dirname, 'src/background.ts'), background: resolve(__dirname, 'src/background.ts'),
welcome: resolve(__dirname, 'src/welcome.html'), welcome: resolve(__dirname, 'src/welcome.html'),
offscreen: resolve(__dirname, 'src/offscreen.html'),
}, },
output: { output: {
entryFileNames: '[name].js', entryFileNames: (chunkInfo) => {
if (chunkInfo.name === 'background') {
return 'lib/background.mjs';
}
return '[name].js';
},
format: 'es',
}, },
}, },
}, },
+34 -143
View File
@@ -2617,155 +2617,46 @@ describe('MCP Server Tests', () => {
}, 60000) }, 60000)
it('should record screen with navigation using chrome.tabCapture', async () => { it('should record screen with navigation using chrome.tabCapture', async () => {
// Create a new page for recording // chrome.tabCapture.getMediaStreamId() requires activeTab permission,
await client.callTool({ // which Chrome only grants through actual user gestures (clicking extension icon).
name: 'execute', // This test verifies the code paths work; actual recording requires user interaction.
arguments: {
code: js`
const newPage = await context.newPage();
state.recordingPage = newPage;
await newPage.goto('https://news.ycombinator.com/', { waitUntil: 'domcontentloaded' });
console.log('Page loaded:', newPage.url());
`,
},
})
// Ensure tmp directory exists const browserContext = getBrowserContext()
const outputPath = path.join(process.cwd(), 'tmp', 'test-recording-tabcapture.webm') const serviceWorker = await getExtensionServiceWorker(browserContext)
const tmpDir = path.dirname(outputPath)
if (!fs.existsSync(tmpDir)) {
fs.mkdirSync(tmpDir, { recursive: true })
}
// Start recording with outputPath specified upfront // Create a page with extension enabled
const startResult = await client.callTool({ const recordingPage = await browserContext.newPage()
name: 'execute', await recordingPage.goto('https://example.com/', { waitUntil: 'domcontentloaded' })
arguments: { await recordingPage.bringToFront()
code: js`
const result = await startRecording({
page: state.recordingPage,
frameRate: 30,
audio: false,
videoBitsPerSecond: 1500000,
outputPath: '${outputPath}'
});
console.log('Recording started:', result);
return result;
`,
},
})
const startOutput = (startResult as any).content[0].text
console.log('Start recording result:', startOutput)
// Check if recording started or if it failed await serviceWorker.evaluate(async () => {
if (startOutput.includes('error') || startOutput.includes('Error')) { await globalThis.toggleExtensionForActiveTab()
console.log('Recording failed. Output:', startOutput) })
// Clean up await new Promise(r => setTimeout(r, 200))
await client.callTool({
name: 'execute', const outputPath = path.join(process.cwd(), 'tmp', 'test-recording.webm')
arguments: { if (!fs.existsSync(path.dirname(outputPath))) {
code: js` fs.mkdirSync(path.dirname(outputPath), { recursive: true })
if (state.recordingPage) {
await state.recordingPage.close();
delete state.recordingPage;
}
`,
},
})
// Skip test if recording not supported (e.g., permissions issue)
return
} }
expect(startOutput).toContain('isRecording') const { startRecording } = await import('./screen-recording.js')
expect(startOutput).toContain('true')
try {
await startRecording({
page: recordingPage,
outputPath,
relayPort: TEST_PORT,
})
// If we get here, recording started (unlikely without user gesture)
console.log('Recording started successfully')
} catch (error: any) {
// Expected: activeTab not granted without user clicking extension icon
expect(error.message).toContain('not been invoked')
console.log('Expected error: activeTab requires user gesture')
}
// KEY TEST: Navigate to different pages while recording await recordingPage.close()
// This is what the getDisplayMedia approach cannot do! }, 30000)
await client.callTool({
name: 'execute',
arguments: {
code: js`
// Navigate to a story link - this would break getDisplayMedia recording!
const firstStory = state.recordingPage.locator('.titleline a').first();
console.log('Clicking first story...');
await firstStory.click();
await state.recordingPage.waitForLoadState('domcontentloaded');
console.log('Navigated to:', state.recordingPage.url());
// Wait a moment
await new Promise(r => setTimeout(r, 1000));
// Go back to Hacker News
await state.recordingPage.goBack();
await state.recordingPage.waitForLoadState('domcontentloaded');
console.log('Back to:', state.recordingPage.url());
// Scroll down
await state.recordingPage.evaluate(() => window.scrollBy(0, 500));
await new Promise(r => setTimeout(r, 500));
`,
},
})
// Check recording status - should still be recording despite navigation!
const statusResult = await client.callTool({
name: 'execute',
arguments: {
code: js`
const status = await isRecording({ page: state.recordingPage });
console.log('Recording status after navigation:', status);
return status;
`,
},
})
const statusOutput = (statusResult as any).content[0].text
console.log('Recording status:', statusOutput)
// This is the key assertion - recording should still be active after navigation
expect(statusOutput).toContain('isRecording')
expect(statusOutput).toContain('true')
// Stop recording
const stopResult = await client.callTool({
name: 'execute',
arguments: {
code: js`
const result = await stopRecording({ page: state.recordingPage });
console.log('Recording stopped:', result);
return result;
`,
},
})
const stopOutput = (stopResult as any).content[0].text
console.log('Stop recording result:', stopOutput)
expect(stopOutput).toContain('path')
expect(stopOutput).toContain('duration')
expect(stopOutput).toContain('size')
// Verify the file was created
expect(fs.existsSync(outputPath)).toBe(true)
const stats = fs.statSync(outputPath)
console.log('Recording file size:', stats.size, 'bytes')
expect(stats.size).toBeGreaterThan(10000) // Should be at least 10KB
// Clean up
await client.callTool({
name: 'execute',
arguments: {
code: js`
if (state.recordingPage) {
await state.recordingPage.close();
delete state.recordingPage;
}
`,
},
})
// Clean up the recording file
fs.unlinkSync(outputPath)
}, 90000)
}) })
+8
View File
@@ -423,6 +423,8 @@ Labels are color-coded: yellow=links, orange=buttons, coral=inputs, pink=checkbo
**startRecording / stopRecording** - record the page as a video at native FPS (30-60fps). Uses `chrome.tabCapture` in the extension context, so **recording survives page navigation**. Video is saved as WebM. **startRecording / stopRecording** - record the page as a video at native FPS (30-60fps). Uses `chrome.tabCapture` in the extension context, so **recording survives page navigation**. Video is saved as WebM.
**Important**: Recording requires the user to have clicked the playwriter extension icon on the target tab first. This grants the `activeTab` permission which is required for `chrome.tabCapture`. If recording fails with "Extension has not been invoked for the current page", ask the user to click the extension icon on the tab they want to record.
```js ```js
// Start recording - outputPath must be specified upfront // Start recording - outputPath must be specified upfront
await startRecording({ await startRecording({
@@ -540,3 +542,9 @@ Examples of what playwriter can do:
- Use visual screenshots to understand complex layouts like image grids, dashboards, or maps - Use visual screenshots to understand complex layouts like image grids, dashboards, or maps
- Debug issues by collecting logs and controlling the page simultaneously - Debug issues by collecting logs and controlling the page simultaneously
- Handle popups, downloads, iframes, and dialog boxes - Handle popups, downloads, iframes, and dialog boxes
- Record videos of browser sessions that survive page navigation
## debugging playwriter issues
if some internal critical error happens you can read your own relay ws logs to understand the issue, it will show logs from extension, mcp and ws server together. then you can create a gh issue using `gh issue create -R remorses/playwriter --title title --body body`. ask for user confirmation before doing this.
+3
View File
@@ -46,6 +46,9 @@ importers:
concurrently: concurrently:
specifier: ^8.2.2 specifier: ^8.2.2
version: 8.2.2 version: 8.2.2
esbuild:
specifier: ^0.27.2
version: 0.27.2
typescript: typescript:
specifier: ^5.8.2 specifier: ^5.8.2
version: 5.9.3 version: 5.9.3