diff --git a/extension/src/frontend/view/Comment/Comment.tsx b/extension/src/frontend/view/Comment/Comment.tsx new file mode 100644 index 000000000..239eee922 --- /dev/null +++ b/extension/src/frontend/view/Comment/Comment.tsx @@ -0,0 +1,63 @@ +import { nanoid } from 'nanoid' +import { useCallback, useState } from 'react' + +import { getTabId } from '../../utils' +import { useGlobalClass } from '../GlobalStyles' +import { useStudioClient } from '../StudioClientProvider' +import { useEscape } from '../hooks/useEscape' +import { usePreventClick } from '../hooks/usePreventClick' + +import { CommentPopover } from './CommentPopover' + +interface CommentProps { + onClose: () => void +} + +export function Comment({ onClose }: CommentProps) { + const client = useStudioClient() + const [showPopover, setShowPopover] = useState(false) + + useGlobalClass('commenting') + + useEscape(() => { + if (showPopover) { + setShowPopover(false) + return + } + onClose() + }, [showPopover, onClose]) + + usePreventClick({ + callback: () => { + setShowPopover(true) + }, + }) + + const handleSubmit = useCallback( + (text: string) => { + client.send({ + type: 'record-events', + events: [ + { + type: 'comment', + eventId: nanoid(), + timestamp: Date.now(), + tab: getTabId(), + text, + }, + ], + }) + setShowPopover(false) + onClose() + }, + [client, onClose] + ) + + return ( + setShowPopover(false)} + onSubmit={handleSubmit} + /> + ) +} diff --git a/extension/src/frontend/view/Comment/CommentPopover.tsx b/extension/src/frontend/view/Comment/CommentPopover.tsx new file mode 100644 index 000000000..3dd947ce9 --- /dev/null +++ b/extension/src/frontend/view/Comment/CommentPopover.tsx @@ -0,0 +1,128 @@ +import { css } from '@emotion/react' +import { SendHorizontalIcon } from 'lucide-react' +import { useEffect, useRef, useState } from 'react' + +import { Flex } from '@/components/primitives/Flex' +import { IconButton } from '@/components/primitives/IconButton' +import { Input } from '@/components/primitives/Input' + +interface CommentPopoverProps { + open: boolean + onClose: () => void + onSubmit: (text: string) => void +} + +export function CommentPopover({ + open, + onClose, + onSubmit, +}: CommentPopoverProps) { + const [text, setText] = useState('') + const inputRef = useRef(null) + + useEffect(() => { + if (open) { + inputRef.current?.focus() + } else { + setText('') + } + }, [open]) + + if (!open) return null + + const handleSubmit = () => { + const value = text.trim() + if (value) { + onSubmit(value) + onClose() + } + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + handleSubmit() + } + } + + return ( + <> +
+
+ + setText(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Add a comment..." + css={css` + flex: 1; + background: transparent; + padding: 0; + margin: 0; + border: none; + + > input { + padding: 13px; + border: none; + outline: none; + + &:focus { + outline: none; + box-shadow: none; + } + } + `} + /> + + + + +
+ + ) +} diff --git a/extension/src/frontend/view/Comment/index.ts b/extension/src/frontend/view/Comment/index.ts new file mode 100644 index 000000000..ee1fcba4c --- /dev/null +++ b/extension/src/frontend/view/Comment/index.ts @@ -0,0 +1 @@ +export { Comment } from './Comment' diff --git a/extension/src/frontend/view/GlobalStyles.tsx b/extension/src/frontend/view/GlobalStyles.tsx index eaa01da12..575c14d68 100644 --- a/extension/src/frontend/view/GlobalStyles.tsx +++ b/extension/src/frontend/view/GlobalStyles.tsx @@ -4,7 +4,7 @@ import { useEffect } from 'react' const uuid = nanoid() -type GlobalClass = 'inspecting' | 'asserting-text' +type GlobalClass = 'inspecting' | 'asserting-text' | 'commenting' export function useGlobalClass(name: GlobalClass) { useEffect(() => { @@ -37,6 +37,10 @@ export function GlobalStyles() { cursor: text !important; user-select: text !important; } + + .ksix-studio-commenting-${uuid} { + cursor: text !important; + } `} /> ) diff --git a/extension/src/frontend/view/InBrowserControls.tsx b/extension/src/frontend/view/InBrowserControls.tsx index a84e83532..e11527e74 100644 --- a/extension/src/frontend/view/InBrowserControls.tsx +++ b/extension/src/frontend/view/InBrowserControls.tsx @@ -1,5 +1,6 @@ import { useState } from 'react' +import { Comment } from './Comment' import { ElementInspector } from './ElementInspector' import { EventDrawer } from './EventDrawer' import { RemoteHighlights } from './RemoteHighlights' @@ -33,6 +34,7 @@ export function InBrowserControls() { {tool === 'assert-text' && ( )} + {tool === 'add-comment' && } + + + + + { ) }) +it('should emit comments in the script', async ({ expect }) => { + const script = await emitScript({ + defaultScenario: { + nodes: [ + { + type: 'page', + nodeId: 'page', + }, + { + type: 'goto', + nodeId: 'goto', + url: 'https://example.com', + source: 'address-bar', + inputs: { + page: { nodeId: 'page' }, + }, + }, + { + type: 'comment', + nodeId: 'comment-1', + text: 'Expecto Patronum! This test now magically passes', + inputs: { + previous: { nodeId: 'goto' }, + }, + }, + { + type: 'locator', + nodeId: 'locator', + selector: { type: 'css', selector: 'button' }, + inputs: { + page: { nodeId: 'page' }, + }, + }, + { + type: 'click', + nodeId: 'click', + button: 'left', + modifiers: { + ctrl: false, + shift: false, + alt: false, + meta: false, + }, + inputs: { + previous: { nodeId: 'comment-1' }, + locator: { nodeId: 'locator' }, + page: { nodeId: 'page' }, + }, + }, + ], + }, + scenarios: {}, + }) + + await expect(script).toMatchFileSnapshot('__snapshots__/browser/comments.ts') +}) + it('should assert that aria input is indeterminate', async ({ expect }) => { const script = await emitScript({ defaultScenario: { diff --git a/src/codegen/browser/formatting/formatter.ts b/src/codegen/browser/formatting/formatter.ts index e02d76f7d..8cfa07acf 100644 --- a/src/codegen/browser/formatting/formatter.ts +++ b/src/codegen/browser/formatting/formatter.ts @@ -61,10 +61,20 @@ function createPlugin(program: ts.Program): Plugin { print(path: AstPath, options: ParserOptions, print) { const node = path.getNode() + const comments = node?.comment?.split('\n').flatMap((line, index) => { + const comment = ['// ', line] + + if (index > 0) { + return [hardline, ...comment] + } + + return comment + }) + const doc = - node?.comment === undefined + comments === undefined ? estree.print(path, options, print) - : ['// ', node.comment] + : comments return applySpacing(node, doc) }, diff --git a/src/codegen/browser/intermediate/ast.ts b/src/codegen/browser/intermediate/ast.ts index 8b24a635a..2dcf3310f 100644 --- a/src/codegen/browser/intermediate/ast.ts +++ b/src/codegen/browser/intermediate/ast.ts @@ -209,7 +209,12 @@ export interface ExpressionStatement { expression: Expression } -export type Statement = Declaration | ExpressionStatement +export interface CommentStatement { + type: 'Comment' + value: string +} + +export type Statement = Declaration | ExpressionStatement | CommentStatement export type Node = Expression | Statement diff --git a/src/codegen/browser/intermediate/context.ts b/src/codegen/browser/intermediate/context.ts index 728341f55..d6d8f2807 100644 --- a/src/codegen/browser/intermediate/context.ts +++ b/src/codegen/browser/intermediate/context.ts @@ -80,6 +80,10 @@ function buildScenarioGraph(scenario: model.Scenario) { connectPrevious(graph, node) break + case 'comment': + connectPrevious(graph, node) + break + default: return exhaustive(node) } diff --git a/src/codegen/browser/intermediate/index.ts b/src/codegen/browser/intermediate/index.ts index fcc5b545d..bff3fc068 100644 --- a/src/codegen/browser/intermediate/index.ts +++ b/src/codegen/browser/intermediate/index.ts @@ -375,6 +375,13 @@ function emitWaitForNode(context: IntermediateContext, node: m.WaitForNode) { }) } +function emitCommentNode(context: IntermediateContext, node: m.CommentNode) { + context.emit({ + type: 'Comment', + value: node.text, + }) +} + function emitNode(context: IntermediateContext, node: m.TestNode) { switch (node.type) { case 'page': @@ -407,6 +414,9 @@ function emitNode(context: IntermediateContext, node: m.TestNode) { case 'wait-for': return emitWaitForNode(context, node) + case 'comment': + return emitCommentNode(context, node) + default: return exhaustive(node) } diff --git a/src/codegen/browser/intermediate/variables.ts b/src/codegen/browser/intermediate/variables.ts index 3dd18e02f..99dde2d13 100644 --- a/src/codegen/browser/intermediate/variables.ts +++ b/src/codegen/browser/intermediate/variables.ts @@ -213,6 +213,9 @@ function substituteStatement( expression: substituteExpression(node.expression, substitutions), } + case 'Comment': + return node + default: return exhaustive(node) } diff --git a/src/codegen/browser/test.ts b/src/codegen/browser/test.ts index 1cbbe947e..75ac30712 100644 --- a/src/codegen/browser/test.ts +++ b/src/codegen/browser/test.ts @@ -248,6 +248,17 @@ function buildBrowserNodeGraphFromEvents(events: BrowserEvent[]) { } } + case 'comment': { + return { + type: 'comment', + nodeId: event.eventId, + text: event.text, + inputs: { + previous, + }, + } + } + default: return exhaustive(event) } diff --git a/src/codegen/browser/types.ts b/src/codegen/browser/types.ts index 760e97ff2..94f0fc3fb 100644 --- a/src/codegen/browser/types.ts +++ b/src/codegen/browser/types.ts @@ -135,6 +135,14 @@ export interface WaitForNode extends NodeBase { } } +export interface CommentNode extends NodeBase { + type: 'comment' + text: string + inputs: { + previous?: NodeRef + } +} + export type TestNode = | PageNode | GotoNode @@ -146,6 +154,7 @@ export type TestNode = | CheckNode | AssertNode | WaitForNode + | CommentNode export interface Scenario { nodes: TestNode[] diff --git a/src/components/BrowserEventList/EventDescription/EventDescription.tsx b/src/components/BrowserEventList/EventDescription/EventDescription.tsx index aa0cfab0b..7048d5b41 100644 --- a/src/components/BrowserEventList/EventDescription/EventDescription.tsx +++ b/src/components/BrowserEventList/EventDescription/EventDescription.tsx @@ -85,6 +85,13 @@ export function EventDescription({ case 'wait-for': return + case 'comment': + return ( + <> + Added a comment {`"${event.text}"`} + + ) + default: return exhaustive(event) } diff --git a/src/components/BrowserEventList/EventIcon.tsx b/src/components/BrowserEventList/EventIcon.tsx index 02a7392b6..2f9ecef3c 100644 --- a/src/components/BrowserEventList/EventIcon.tsx +++ b/src/components/BrowserEventList/EventIcon.tsx @@ -3,6 +3,7 @@ import { CircleDotIcon, CircleIcon, ClipboardListIcon, + MessageSquareTextIcon, EyeIcon, GlobeIcon, ListFilterPlusIcon, @@ -51,6 +52,9 @@ export function EventIcon({ event }: EventIconProps) { case 'wait-for': return + case 'comment': + return + default: return exhaustive(event) } diff --git a/src/schemas/recording/browser/v2/index.ts b/src/schemas/recording/browser/v2/index.ts index 34d2e9920..ea1349b84 100644 --- a/src/schemas/recording/browser/v2/index.ts +++ b/src/schemas/recording/browser/v2/index.ts @@ -162,6 +162,12 @@ const WaitForEventSchema = BrowserEventBaseSchema.extend({ .optional(), }) +const CommentEventSchema = BrowserEventBaseSchema.extend({ + type: z.literal('comment'), + tab: z.string(), + text: z.string(), +}) + export const BrowserEventSchema = z.discriminatedUnion('type', [ NavigateToPageEventSchema, ReloadPageEventSchema, @@ -173,6 +179,7 @@ export const BrowserEventSchema = z.discriminatedUnion('type', [ SubmitFormEventSchema, AssertEventSchema, WaitForEventSchema, + CommentEventSchema, ]) export const BrowserEventsSchema = z.object({ @@ -197,6 +204,7 @@ export type SelectChangeEvent = z.infer export type SubmitFormEvent = z.infer export type AssertEvent = z.infer export type WaitForEvent = z.infer +export type CommentEvent = z.infer export type TextAssertion = z.infer export type VisibilityAssertion = z.infer