diff --git a/apps/extension/scripts/test-e2e.ts b/apps/extension/scripts/test-e2e.ts index 50c7b68f58c..8d1da01cd73 100644 --- a/apps/extension/scripts/test-e2e.ts +++ b/apps/extension/scripts/test-e2e.ts @@ -532,14 +532,28 @@ try { } assert(snapshot.url === `${pageOrigin}/ticket`, 'Source URL contains query or fragment') }) - await check('Selection attaches only the selected excerpt', async () => { + await check('A focused read-only custom element attaches only the selected excerpt', async () => { await page.evaluate(() => { const selected = document.getElementById('selected') if (!selected) throw new Error('Missing selection fixture') + customElements.define('readable-article', class extends HTMLElement {}) + const host = document.createElement('readable-article') + host.tabIndex = 0 + selected.replaceWith(host) + host.append(selected) + host.focus() const range = document.createRange() range.selectNodeContents(selected) - window.getSelection()?.removeAllRanges() - window.getSelection()?.addRange(range) + const selection = window.getSelection() + selection?.removeAllRanges() + selection?.addRange(range) + if ( + document.activeElement !== host || + host.isContentEditable || + host.shadowRoot || + selection?.toString() !== selected.textContent + ) + throw new Error('Fixture did not focus and select ordinary custom-element text') }) await capture('Selection') await app.waitForFunction( @@ -551,6 +565,7 @@ try { snapshot.text === 'The customer needs a replacement by Friday.', 'Selection widened beyond the user excerpt' ) + await page.locator('readable-article').evaluate((host) => host.replaceWith(...host.childNodes)) }) await check('A focused child frame cannot substitute an old parent-page selection', async () => { await page.evaluate(() => { diff --git a/apps/extension/src/background.ts b/apps/extension/src/background.ts index 81f6ceb3c7f..b0a76732c7f 100644 --- a/apps/extension/src/background.ts +++ b/apps/extension/src/background.ts @@ -55,7 +55,6 @@ function open(tab: chrome.tabs.Tab, trigger: 'page' | 'selection' | 'keyboard') active instanceof HTMLElement && (active.isContentEditable || active.matches('iframe,frame,input,textarea,select') || - active.localName.includes('-') || Boolean(chrome.dom.openOrClosedShadowRoot(active)?.activeElement)) return { href: location.href, diff --git a/apps/extension/src/extract.ts b/apps/extension/src/extract.ts index 8d0ff20c040..b96882f64f2 100644 --- a/apps/extension/src/extract.ts +++ b/apps/extension/src/extract.ts @@ -21,7 +21,6 @@ export function extractPageText( active instanceof HTMLElement && (active.isContentEditable || active.matches('input,textarea,select') || - active.localName.includes('-') || chrome.dom.openOrClosedShadowRoot(active)?.activeElement) ) return { error: 'Select text outside an editable or embedded field.' } diff --git a/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx b/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx index a4c0aaf5e5b..3d9248a4d38 100644 --- a/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx +++ b/apps/sim/app/o/[organizationId]/home/components/composer/composer.tsx @@ -203,7 +203,8 @@ export function Composer({ history.reset() editor.clear() }, - () => editorRef.current.getValue() === raw && attachedFilesRef.current === filesAtSend + () => editorRef.current.getValue() === raw && attachedFilesRef.current === filesAtSend, + textareaRef.current ) } diff --git a/apps/sim/app/sim-links/[organizationId]/[name]/page.tsx b/apps/sim/app/sim-links/[organizationId]/[name]/page.tsx index 57ea2d776c3..b1721f0b126 100644 --- a/apps/sim/app/sim-links/[organizationId]/[name]/page.tsx +++ b/apps/sim/app/sim-links/[organizationId]/[name]/page.tsx @@ -13,7 +13,8 @@ interface OrganizationLinkPageProps { /** Bookmarks stay in their exporting organization even after the browser's active team changes. */ export default async function OrganizationLinkPage({ params }: OrganizationLinkPageProps) { - const { organizationId, name } = await params + const { organizationId, name: encodedName } = await params + const name = decodeURIComponent(encodedName) if (!simLinkNameSchema.safeParse(name).success) notFound() const routes = organizationRoutes(organizationId) const session = await getSession() diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx index 3d45acba81c..c857e50a319 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/user-input.tsx @@ -574,7 +574,8 @@ const UserInputImpl = forwardRef(function UserI clearComposer, () => editorRef.current.getValue() === raw && - filesRef.current.attachedFiles === currentFiles.attachedFiles + filesRef.current.attachedFiles === currentFiles.attachedFiles, + currentEditor.textareaRef.current ) }, [chatPlanRequired, workspaceId, onSubmit, clearComposer, acceptSubmission]) diff --git a/apps/sim/hooks/use-composer-submission.ts b/apps/sim/hooks/use-composer-submission.ts index 1dae54c5e10..a0b3b018499 100644 --- a/apps/sim/hooks/use-composer-submission.ts +++ b/apps/sim/hooks/use-composer-submission.ts @@ -26,28 +26,40 @@ interface UseComposerSubmissionProps { export function useComposerSubmission({ scope, mode }: UseComposerSubmissionProps) { const active = useRef(null) - const focusBeforeSend = useRef(null) + const focusAfterSend = useRef<{ + input: HTMLTextAreaElement + initiator: Element | null + } | null>(null) const [pending, setPending] = useState(false) useLayoutEffect(() => { setPending(false) return () => { active.current = null - focusBeforeSend.current = null + focusAfterSend.current = null } }, [scope, mode]) useLayoutEffect(() => { if (pending) return - const previous = focusBeforeSend.current - focusBeforeSend.current = null - if (previous?.isConnected && document.hasFocus() && document.activeElement === document.body) { - previous.focus({ preventScroll: true }) + const focus = focusAfterSend.current + focusAfterSend.current = null + if ( + focus?.input.isConnected && + document.hasFocus() && + (document.activeElement === document.body || document.activeElement === focus.initiator) + ) { + focus.input.focus({ preventScroll: true }) } }, [pending]) const accept = useCallback( - (submit: () => unknown, clear: () => void, unchanged: () => boolean) => { + ( + submit: () => unknown, + clear: () => void, + unchanged: () => boolean, + input: HTMLTextAreaElement | null + ) => { if (active.current) return const result = submit() if (!(result instanceof PendingComposerSubmission)) { @@ -55,8 +67,7 @@ export function useComposerSubmission({ scope, mode }: UseComposerSubmissionProp return } active.current = result - focusBeforeSend.current = - document.activeElement instanceof HTMLElement ? document.activeElement : null + focusAfterSend.current = input ? { input, initiator: document.activeElement } : null setPending(true) void result.ready .then((commit) => { diff --git a/apps/sim/proxy.ts b/apps/sim/proxy.ts index 93e8aea8b7b..2ef79f36f36 100644 --- a/apps/sim/proxy.ts +++ b/apps/sim/proxy.ts @@ -233,7 +233,7 @@ const SUSPICIOUS_UA_PATTERNS = [ ] as const /** - * Handles authentication-based redirects for root paths + * Resolves legacy homepage aliases and authentication-based root redirects. */ function handleRootPathRedirects( request: NextRequest, @@ -241,6 +241,12 @@ function handleRootPathRedirects( ): NextResponse | null { const url = request.nextUrl + if (url.pathname !== '/sim' && url.pathname.toLowerCase() === '/sim') { + const destination = url.clone() + destination.pathname = '/' + return NextResponse.redirect(destination, 308) + } + if (url.pathname !== '/' || url.searchParams.has('home')) { return null } diff --git a/apps/sim/scripts/test-browser-extension-e2e.ts b/apps/sim/scripts/test-browser-extension-e2e.ts index 3a0aa695a99..3446e1984de 100644 --- a/apps/sim/scripts/test-browser-extension-e2e.ts +++ b/apps/sim/scripts/test-browser-extension-e2e.ts @@ -2023,35 +2023,52 @@ try { assert.equal(manual.length, 1) assert(manual[0].text.includes('ORCHID-482') && !manual[0].text.includes('JADE-231')) - await openScenario(target, `${org ? 'organization' : 'workspace'}-denied`) - await expect(input()).toBeVisible() - await restricted.bringToFront() - const draft = 'Keep this draft if access is unavailable' - await input().fill(draft) - count = received.length - phase = 'denied-send' - await input().press('Enter') - await expect( - shell.getByRole('button', { name: 'Request site access', exact: true }) - ).toBeEnabled() - await expect(input()).toHaveValue(draft) - assert.equal(received.length, count) - await screenshot(`reference-${org ? 'organization' : 'workspace'}-denied`) - const accessNotice = await shell - .getByRole('status') - .first() - .evaluate((status) => status.firstChild?.textContent?.trim()) - assert(accessNotice) - assert.equal( - await frame().getByText(accessNotice, { exact: true }).count(), - 0, - 'Access guidance covered the composer twice' - ) - await setReference(false) - phase = 'opt-out-send' - await input().press('Enter') - await expect.poll(() => received.length).toBe(count + 1) - assert.equal(submittedPages(received[count]).length, 0) + for (const gesture of ['keyboard', 'mouse'] as const) { + await openScenario(target, `${org ? 'organization' : 'workspace'}-denied-${gesture}`) + await expect(input()).toBeVisible() + await restricted.bringToFront() + const draft = 'Keep this draft if access is unavailable' + await input().fill(draft) + count = received.length + phase = `denied-${gesture}-send` + if (gesture === 'keyboard') await input().press('Enter') + else + await frame() + .getByRole('button', { name: org ? 'Send' : 'Send message', exact: true }) + .click() + await expect( + shell.getByRole('button', { name: 'Request site access', exact: true }) + ).toBeEnabled() + await expect(input()).toHaveValue(draft) + await expect + .poll(() => input().evaluate((element) => Boolean(element.closest('[inert]')))) + .toBe(false) + assert.equal(received.length, count) + assert( + await frame().evaluate(() => document.hasFocus()), + `The denied ${gesture} send must keep the chat document focused` + ) + await expect(input()).toBeFocused() + await shell.keyboard.type(' Continue typing after the denied capture.') + await expect(input()).toHaveValue(`${draft} Continue typing after the denied capture.`) + assert.equal(received.length, count) + await screenshot(`reference-${org ? 'organization' : 'workspace'}-denied-${gesture}`) + const accessNotice = await shell + .getByRole('status') + .first() + .evaluate((status) => status.firstChild?.textContent?.trim()) + assert(accessNotice) + assert.equal( + await frame().getByText(accessNotice, { exact: true }).count(), + 0, + 'Access guidance covered the composer twice' + ) + await setReference(false) + phase = 'opt-out-send' + await input().press('Enter') + await expect.poll(() => received.length).toBe(count + 1) + assert.equal(submittedPages(received[count]).length, 0) + } } } finally { await browserContext.unroute(endpoint) diff --git a/apps/sim/scripts/test-landing-seo-e2e.ts b/apps/sim/scripts/test-landing-seo-e2e.ts index bbde6787ee1..6b7171bff39 100644 --- a/apps/sim/scripts/test-landing-seo-e2e.ts +++ b/apps/sim/scripts/test-landing-seo-e2e.ts @@ -327,6 +327,34 @@ async function run() { assert.deepEqual(failures, []) }) + await check( + 'legacy Sim aliases preserve queries without redirecting the Sim Links entry', + async () => { + const entry = await get('/sim') + assert.equal(entry.status, 307) + assert(entry.location, 'The Sim Links entry must offer sign-in') + const login = new URL(entry.location, baseUrl) + assert.equal(login.pathname, '/login') + assert.equal(login.searchParams.get('callbackUrl'), '/sim') + for (const path of ['/Sim', '/SIM', '/sIm']) { + const { status, location } = await get(`${path}?home&utm_source=legacy`) + assert.equal(status, 308, path) + assert(location, `${path}: expected the legacy homepage redirect`) + const target = new URL(location, baseUrl) + assert.equal(target.origin, baseUrl.origin, path) + assert.equal(target.pathname, '/', path) + assert.deepEqual( + [...target.searchParams], + [ + ['home', ''], + ['utm_source', 'legacy'], + ], + path + ) + } + } + ) + await check('integration sitemap dates are per integration, not one catalog date', async () => { const mismatches: string[] = [] for (const integration of integrations) { diff --git a/apps/sim/scripts/test-sim-links-e2e.ts b/apps/sim/scripts/test-sim-links-e2e.ts index 29fe5c68115..a3edd66b422 100644 --- a/apps/sim/scripts/test-sim-links-e2e.ts +++ b/apps/sim/scripts/test-sim-links-e2e.ts @@ -403,24 +403,30 @@ async function run() { await api({ ...outsider, orgId }, '', { expected: 404 }) }) - await check( - 'a team bookmark keeps its organization across sign-in and a same-name link elsewhere', - async () => { - const name = 'managed-help' - const path = `/sim-links/${encodeURIComponent(orgId)}/${name}` - const destination = 'https://team.example.com/help' + await check('team bookmarks preserve encoded names and organization across sign-in', async () => { + const destination = 'https://team.example.com/help' + for (const name of ['managed-help', 'managed:help@team&docs']) { + const path = `/sim-links/${encodeURIComponent(orgId)}/${encodeURIComponent(name)}` await create(owner, { name, url: destination }) await create(outsider, { name, url: 'https://another-team.example.com/help' }) const signedOut = await send(path, null) + assert.equal(signedOut.status, 307, `${name}: expected sign-in redirect`) const login = new URL(signedOut.headers.get('location') ?? '/', baseUrl) - assert.equal(login.pathname, '/login') - assert.equal(login.searchParams.get('callbackUrl'), path) - assert.equal((await send(path, member)).headers.get('location'), destination) + assert.equal(login.pathname, '/login', name) + assert.equal(login.searchParams.get('callbackUrl'), path, name) + const opened = await send(path, member) + assert.equal(opened.status, 307, `${name}: expected destination redirect`) + assert.equal(opened.headers.get('location'), destination, name) const denied = await send(path, outsider) - assert.equal(denied.status, 404) - assert(!denied.headers.get('location')) + assert.equal(denied.status, 404, name) + assert(!denied.headers.get('location'), name) } - ) + for (const encodedName of ['%256Danaged-help', 'managed%2Fhelp']) { + const denied = await send(`/sim-links/${encodeURIComponent(orgId)}/${encodedName}`, member) + assert.equal(denied.status, 404, encodedName) + assert(!denied.headers.get('location'), encodedName) + } + }) await check( 'bookmark export requires admin access and drops stale or foreign selections',