Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 7 additions & 11 deletions apps/desktop/e2e/ask-user-question.spec.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
import { FAKE_ASK_USER_QUESTION_PROMPT } from '@maka/runtime';
import { test, expect, COMPOSER_INPUT } from './fixtures.js';

test('rehydrates a prompt the surface never received live', async ({ window: page }) => {
// One seeded prompt, one launch: the reload-rehydration contract and the
// answer flow are consecutive phases of the same parked turn. Answering
// *after* the reload is the stronger form of both tests — it proves the
// rehydrated prompt is not a rendering of lost state but the live turn.
test('rehydrates a prompt across reload, then answers all three questions in the same turn', async ({
window: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill(FAKE_ASK_USER_QUESTION_PROMPT);
await composer.press('Enter');
Expand All@@ -20,16 +26,6 @@ test('rehydrates a prompt the surface never received live', async ({ window: pag
.first()
.click();

await expect(prompt).toBeVisible();
await expect(prompt.getByText('1 / 3', { exact: true })).toBeVisible();
});

test('answers three questions and continues the same fake-backend turn', async ({ window: page }) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill(FAKE_ASK_USER_QUESTION_PROMPT);
await composer.press('Enter');

const prompt = page.locator('.maka-user-question-prompt');
await expect(prompt).toBeVisible();
await expect(page.locator('.maka-composer')).toBeHidden();
await expect(prompt.getByText('1 / 3', { exact: true })).toBeVisible();
Expand Down
58 changes: 27 additions & 31 deletions apps/desktop/e2e/attachment.spec.ts
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,12 @@
import { test, expect, COMPOSER_INPUT } from './fixtures';

test('chat input preserves an IME composition when a file paste arrives', async ({ window: page }) => {
const firstSend = page.locator(COMPOSER_INPUT);
await firstSend.fill('ime-paste-test');
await firstSend.press('Enter');
await expect(page.getByText(/Fake backend received: ime-paste-test/)).toBeVisible();

const composer = page.locator('.maka-composer[data-maka-file-drop-target="true"]');
const editable = composer.locator('[contenteditable="true"]');

const pasteResults = await editable.evaluate((input) => {
const dispatchFilePaste = () => {
const clipboardData = new DataTransfer();
clipboardData.items.add(new File(['content'], 'note.txt', { type: 'text/plain' }));
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', { value: clipboardData });
input.dispatchEvent(event);
return event.defaultPrevented;
};

input.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }));
const duringComposition = dispatchFilePaste();
input.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }));
const afterComposition = dispatchFilePaste();

return { duringComposition, afterComposition };
});

expect(pasteResults).toEqual({ duringComposition: false, afterComposition: true });
});

/**
* Attachment upload + ingest: enter the chat view, drop a file onto the main
* composer, confirm it shows as a pending token, then send the message and
* verify the fake backend received the attachments by name. Uses Playwright's
* DataTransfer + dispatchEvent because the composer has no <input type=file>.
*/
test('a mixed attachment send has the Astryx message hierarchy', async ({ window: page }) => {
test('a mixed attachment send has the Astryx message hierarchy, and IME composition survives a file paste', async ({ window: page }) => {
// Enter chat view by sending a first message from the composer, which
// creates the session on send.
const firstSend = page.locator(COMPOSER_INPUT);
Expand DownExpand Up@@ -152,4 +122,30 @@ test('a mixed attachment send has the Astryx message hierarchy', async ({ window

await page.keyboard.press('Escape');
await expect(page.locator('.astryx-lightbox')).not.toBeVisible();

// IME composition vs file paste, in the same window: pastes must not be
// intercepted mid-composition, and must be intercepted after it. Runs after
// the attachment journey because the post-composition paste stages a chip.
const editable = page
.locator('.maka-composer[data-maka-file-drop-target="true"]')
.locator('[contenteditable="true"]');
const pasteResults = await editable.evaluate((input) => {
const dispatchFilePaste = () => {
const clipboardData = new DataTransfer();
clipboardData.items.add(new File(['content'], 'note.txt', { type: 'text/plain' }));
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', { value: clipboardData });
input.dispatchEvent(event);
return event.defaultPrevented;
};

input.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }));
const duringComposition = dispatchFilePaste();
input.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }));
const afterComposition = dispatchFilePaste();

return { duringComposition, afterComposition };
});

expect(pasteResults).toEqual({ duringComposition: false, afterComposition: true });
});
9 changes: 4 additions & 5 deletions apps/desktop/e2e/bot-onboarding.spec.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
import { test, expect } from './fixtures';

test('IM 快捷接入完成真实 QR session、扫码状态和本机凭据落盘', async ({ botSettingsWindow: page }) => {
test('IM 快捷接入完成真实 QR session、凭据落盘,取消与过期二维码可恢复', async ({ botSettingsWindow: page }) => {
const settings = page.getByRole('main', { name: '设置内容' });
await expect(settings.getByRole('heading', { name: '远程接入' })).toBeVisible();

Expand DownExpand Up@@ -32,11 +32,10 @@ test('IM 快捷接入完成真实 QR session、扫码状态和本机凭据落盘

await dialog.getByRole('button', { name: '完成' }).click();
await expect(dialog).toBeHidden();
});

test('关闭扫码弹窗会取消迟到结果,过期二维码可以重新生成', async ({ botSettingsWindow: page }) => {
const settings = page.getByRole('main', { name: '设置内容' });

// Same window, next channels: cancellation races, expiry regeneration, and
// the Lark variant are independent flows over the same seeded settings.
await settings.getByRole('button', { name: '返回远程接入' }).click();
await settings.getByRole('button', { name: '接入 微信' }).click();
await settings.getByRole('button', { name: '扫码登录' }).click();
const wechatDialog = page.getByRole('dialog', { name: '微信扫码登录' });
Expand Down
150 changes: 72 additions & 78 deletions apps/desktop/e2e/composer-mention-token.spec.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,28 @@
import { expect, test, COMPOSER_INPUT } from './fixtures';

// One seeded window, four phases over the same composer. Order is dictated
// by the message-count pins: the empty-menu double-Enter phase asserts a
// total of exactly one sent message, so it runs first; the token phase sends
// last and anchors on the newest bubble.
/**
* An open menu with nothing highlighted (still loading, or no matches) leaves
* Enter unconsumed. It must not send the draft out from under the popup — and
* it must not deadlock either: "no matches" is a stable state, so swallowing
* Enter forever would leave the keyboard unable to send at all.
*/
/**
* The menu has to follow the caret, not just the text. Astryx recomputes the
* active trigger only on `input`, so an arrow key off the query used to leave
* the menu open over a trigger no longer under the cursor — and the next Enter
* spliced a token in at the stale offset instead of sending.
*/
/**
* Upstream contract: the trigger boundaries are Astryx's `findActiveTrigger`
* now, and it is NOT equivalent to the `detectMentionTrigger` it replaced — a
* space ends an `@` query, so a path with a space in it can no longer be
* searched. Pin the grammar we actually depend on so an Astryx upgrade that
* moves a boundary fails here rather than in front of a user.
*/
/**
* The `@` file trigger: menu → inline token → the path the backend receives.
* The token is a real chip in the draft now, so this also pins the one cascade
Expand All@@ -8,10 +31,55 @@ import { expect, test, COMPOSER_INPUT } from './fixtures';
* `contenteditable="false"` stretched it to the full line and pushed the
* surrounding text onto separate rows.
*/
test('a picked file mention becomes an inline token and sends as its path', async ({
test('the @ trigger: empty-menu sends, caret boundaries, trigger grammar, and the inline token round trip', async ({
invocableSkillsWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('@zzzznomatchzzzz');
await expect(page.getByRole('listbox', { name: '工作区文件' })).toBeVisible();

// A leaked send is asynchronous, so `toHaveCount(0)` here would pass before
// it lands — and a second Enter sending the same text would then hide it.
// Withhold, retype into something distinguishable, and pin the total.
await composer.press('Enter');
await composer.press('Enter');
await expect(page.getByText('Fake backend received: @zzzznomatchzzzz')).toBeVisible();
await expect(page.getByLabel('你发送的消息')).toHaveCount(1);
// Settle before the next phase sends: an Enter during a streaming turn
// becomes steering instead of a new message.
await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { timeout: 20_000 });

await composer.fill('看一下 @agent');
await expect(page.getByRole('listbox', { name: '工作区文件' })).toBeVisible();

for (let index = 0; index < 6; index += 1) await composer.press('ArrowLeft');
await expect.poll(() => composer.getAttribute('aria-expanded')).toBe('false');

await composer.press('Enter');
await expect(page.getByText('Fake backend received: 看一下 @agent')).toBeVisible();
await expect(composer.locator('[data-astryx-token]')).toHaveCount(0);
await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(2, { timeout: 20_000 });

const expanded = () => composer.getAttribute('aria-expanded');
const files = page.getByRole('listbox', { name: '工作区文件' });

await composer.fill('看一下 @agent');
await expect(files).toBeVisible();

// A space ends the query — narrowing an `@` search by a second word, which
// the retired popup allowed, is gone.
await composer.pressSequentially(' write');
await expect.poll(expanded).toBe('false');

// A non-boundary `@` is not a trigger.
await composer.fill('mail user@host.com');
await expect.poll(expanded).toBe('false');

// The nearest boundary wins.
await composer.fill('@a /b');
await expect(page.getByRole('listbox', { name: /技能/ })).toBeVisible();
await expect(files).toHaveCount(0);

await composer.fill('看一下 @agent');

const listbox = page.getByRole('listbox', { name: '工作区文件' });
Expand All@@ -33,7 +101,8 @@ test('a picked file mention becomes an inline token and sends as its path', asyn
expect(tokenWidth).toBeLessThan(lineWidth / 2);

await composer.press('Enter');
const bubble = page.getByLabel('你发送的消息').first();
// Third send of this journey: anchor on the newest bubble.
const bubble = page.getByLabel('你发送的消息').last();
await expect(bubble).toBeVisible();
const sentFileBadges = bubble.locator('.maka-chat-message-bubble-user .astryx-badge');
await expect(sentFileBadges).toHaveCount(1);
Expand All@@ -50,7 +119,7 @@ test('a picked file mention becomes an inline token and sends as its path', asyn
).toBeVisible();

await page.reload();
const reloadedBubble = page.getByLabel('你发送的消息').first();
const reloadedBubble = page.getByLabel('你发送的消息').last();
await expect(reloadedBubble).toBeVisible();
await expect(
reloadedBubble.locator('.maka-chat-message-bubble-user .astryx-badge'),
Expand All@@ -59,78 +128,3 @@ test('a picked file mention becomes an inline token and sends as its path', asyn
'普通文本 @.maka/skills/agent-write/SKILL.md',
);
});

/**
* Upstream contract: the trigger boundaries are Astryx's `findActiveTrigger`
* now, and it is NOT equivalent to the `detectMentionTrigger` it replaced — a
* space ends an `@` query, so a path with a space in it can no longer be
* searched. Pin the grammar we actually depend on so an Astryx upgrade that
* moves a boundary fails here rather than in front of a user.
*/
test('the trigger menu opens exactly on the boundaries we depend on', async ({
invocableSkillsWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
const expanded = () => composer.getAttribute('aria-expanded');
const files = page.getByRole('listbox', { name: '工作区文件' });

await composer.fill('看一下 @agent');
await expect(files).toBeVisible();

// A space ends the query — narrowing an `@` search by a second word, which
// the retired popup allowed, is gone.
await composer.pressSequentially(' write');
await expect.poll(expanded).toBe('false');

// A non-boundary `@` is not a trigger.
await composer.fill('mail user@host.com');
await expect.poll(expanded).toBe('false');

// The nearest boundary wins.
await composer.fill('@a /b');
await expect(page.getByRole('listbox', { name: /技能/ })).toBeVisible();
await expect(files).toHaveCount(0);
});

/**
* An open menu with nothing highlighted (still loading, or no matches) leaves
* Enter unconsumed. It must not send the draft out from under the popup — and
* it must not deadlock either: "no matches" is a stable state, so swallowing
* Enter forever would leave the keyboard unable to send at all.
*/
test('Enter with an open, empty trigger menu withholds one send, not every send', async ({
invocableSkillsWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('@zzzznomatchzzzz');
await expect(page.getByRole('listbox', { name: '工作区文件' })).toBeVisible();

// A leaked send is asynchronous, so `toHaveCount(0)` here would pass before
// it lands — and a second Enter sending the same text would then hide it.
// Withhold, retype into something distinguishable, and pin the total.
await composer.press('Enter');
await composer.press('Enter');
await expect(page.getByText('Fake backend received: @zzzznomatchzzzz')).toBeVisible();
await expect(page.getByLabel('你发送的消息')).toHaveCount(1);
});

/**
* The menu has to follow the caret, not just the text. Astryx recomputes the
* active trigger only on `input`, so an arrow key off the query used to leave
* the menu open over a trigger no longer under the cursor — and the next Enter
* spliced a token in at the stale offset instead of sending.
*/
test('moving the caret off the query closes the trigger menu', async ({
invocableSkillsWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('看一下 @agent');
await expect(page.getByRole('listbox', { name: '工作区文件' })).toBeVisible();

for (let index = 0; index < 6; index += 1) await composer.press('ArrowLeft');
await expect.poll(() => composer.getAttribute('aria-expanded')).toBe('false');

await composer.press('Enter');
await expect(page.getByText('Fake backend received: 看一下 @agent')).toBeVisible();
await expect(composer.locator('[data-astryx-token]')).toHaveCount(0);
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
test(desktop): merge same-fixture e2e assertions into coherent journeys by UncertaintyDeterminesYou4ndMe · Pull Request #2486 · apache/maka · GitHub
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 7 additions & 11 deletions apps/desktop/e2e/ask-user-question.spec.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
import { FAKE_ASK_USER_QUESTION_PROMPT } from '@maka/runtime';
import { test, expect, COMPOSER_INPUT } from './fixtures.js';

test('rehydrates a prompt the surface never received live', async ({ window: page }) => {
// One seeded prompt, one launch: the reload-rehydration contract and the
// answer flow are consecutive phases of the same parked turn. Answering
// *after* the reload is the stronger form of both tests — it proves the
// rehydrated prompt is not a rendering of lost state but the live turn.
test('rehydrates a prompt across reload, then answers all three questions in the same turn', async ({
window: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill(FAKE_ASK_USER_QUESTION_PROMPT);
await composer.press('Enter');
Expand All@@ -20,16 +26,6 @@ test('rehydrates a prompt the surface never received live', async ({ window: pag
.first()
.click();

await expect(prompt).toBeVisible();
await expect(prompt.getByText('1 / 3', { exact: true })).toBeVisible();
});

test('answers three questions and continues the same fake-backend turn', async ({ window: page }) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill(FAKE_ASK_USER_QUESTION_PROMPT);
await composer.press('Enter');

const prompt = page.locator('.maka-user-question-prompt');
await expect(prompt).toBeVisible();
await expect(page.locator('.maka-composer')).toBeHidden();
await expect(prompt.getByText('1 / 3', { exact: true })).toBeVisible();
Expand Down
58 changes: 27 additions & 31 deletions apps/desktop/e2e/attachment.spec.ts
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,12 @@
import { test, expect, COMPOSER_INPUT } from './fixtures';

test('chat input preserves an IME composition when a file paste arrives', async ({ window: page }) => {
const firstSend = page.locator(COMPOSER_INPUT);
await firstSend.fill('ime-paste-test');
await firstSend.press('Enter');
await expect(page.getByText(/Fake backend received: ime-paste-test/)).toBeVisible();

const composer = page.locator('.maka-composer[data-maka-file-drop-target="true"]');
const editable = composer.locator('[contenteditable="true"]');

const pasteResults = await editable.evaluate((input) => {
const dispatchFilePaste = () => {
const clipboardData = new DataTransfer();
clipboardData.items.add(new File(['content'], 'note.txt', { type: 'text/plain' }));
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', { value: clipboardData });
input.dispatchEvent(event);
return event.defaultPrevented;
};

input.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }));
const duringComposition = dispatchFilePaste();
input.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }));
const afterComposition = dispatchFilePaste();

return { duringComposition, afterComposition };
});

expect(pasteResults).toEqual({ duringComposition: false, afterComposition: true });
});

/**
* Attachment upload + ingest: enter the chat view, drop a file onto the main
* composer, confirm it shows as a pending token, then send the message and
* verify the fake backend received the attachments by name. Uses Playwright's
* DataTransfer + dispatchEvent because the composer has no <input type=file>.
*/
test('a mixed attachment send has the Astryx message hierarchy', async ({ window: page }) => {
test('a mixed attachment send has the Astryx message hierarchy, and IME composition survives a file paste', async ({ window: page }) => {
// Enter chat view by sending a first message from the composer, which
// creates the session on send.
const firstSend = page.locator(COMPOSER_INPUT);
Expand DownExpand Up@@ -152,4 +122,30 @@ test('a mixed attachment send has the Astryx message hierarchy', async ({ window

await page.keyboard.press('Escape');
await expect(page.locator('.astryx-lightbox')).not.toBeVisible();

// IME composition vs file paste, in the same window: pastes must not be
// intercepted mid-composition, and must be intercepted after it. Runs after
// the attachment journey because the post-composition paste stages a chip.
const editable = page
.locator('.maka-composer[data-maka-file-drop-target="true"]')
.locator('[contenteditable="true"]');
const pasteResults = await editable.evaluate((input) => {
const dispatchFilePaste = () => {
const clipboardData = new DataTransfer();
clipboardData.items.add(new File(['content'], 'note.txt', { type: 'text/plain' }));
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', { value: clipboardData });
input.dispatchEvent(event);
return event.defaultPrevented;
};

input.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }));
const duringComposition = dispatchFilePaste();
input.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }));
const afterComposition = dispatchFilePaste();

return { duringComposition, afterComposition };
});

expect(pasteResults).toEqual({ duringComposition: false, afterComposition: true });
});
9 changes: 4 additions & 5 deletions apps/desktop/e2e/bot-onboarding.spec.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
import { test, expect } from './fixtures';

test('IM 快捷接入完成真实 QR session、扫码状态和本机凭据落盘', async ({ botSettingsWindow: page }) => {
test('IM 快捷接入完成真实 QR session、凭据落盘,取消与过期二维码可恢复', async ({ botSettingsWindow: page }) => {
const settings = page.getByRole('main', { name: '设置内容' });
await expect(settings.getByRole('heading', { name: '远程接入' })).toBeVisible();

Expand DownExpand Up@@ -32,11 +32,10 @@ test('IM 快捷接入完成真实 QR session、扫码状态和本机凭据落盘

await dialog.getByRole('button', { name: '完成' }).click();
await expect(dialog).toBeHidden();
});

test('关闭扫码弹窗会取消迟到结果,过期二维码可以重新生成', async ({ botSettingsWindow: page }) => {
const settings = page.getByRole('main', { name: '设置内容' });

// Same window, next channels: cancellation races, expiry regeneration, and
// the Lark variant are independent flows over the same seeded settings.
await settings.getByRole('button', { name: '返回远程接入' }).click();
await settings.getByRole('button', { name: '接入 微信' }).click();
await settings.getByRole('button', { name: '扫码登录' }).click();
const wechatDialog = page.getByRole('dialog', { name: '微信扫码登录' });
Expand Down
150 changes: 72 additions & 78 deletions apps/desktop/e2e/composer-mention-token.spec.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,28 @@
import { expect, test, COMPOSER_INPUT } from './fixtures';

// One seeded window, four phases over the same composer. Order is dictated
// by the message-count pins: the empty-menu double-Enter phase asserts a
// total of exactly one sent message, so it runs first; the token phase sends
// last and anchors on the newest bubble.
/**
* An open menu with nothing highlighted (still loading, or no matches) leaves
* Enter unconsumed. It must not send the draft out from under the popup — and
* it must not deadlock either: "no matches" is a stable state, so swallowing
* Enter forever would leave the keyboard unable to send at all.
*/
/**
* The menu has to follow the caret, not just the text. Astryx recomputes the
* active trigger only on `input`, so an arrow key off the query used to leave
* the menu open over a trigger no longer under the cursor — and the next Enter
* spliced a token in at the stale offset instead of sending.
*/
/**
* Upstream contract: the trigger boundaries are Astryx's `findActiveTrigger`
* now, and it is NOT equivalent to the `detectMentionTrigger` it replaced — a
* space ends an `@` query, so a path with a space in it can no longer be
* searched. Pin the grammar we actually depend on so an Astryx upgrade that
* moves a boundary fails here rather than in front of a user.
*/
/**
* The `@` file trigger: menu → inline token → the path the backend receives.
* The token is a real chip in the draft now, so this also pins the one cascade
Expand All@@ -8,10 +31,55 @@ import { expect, test, COMPOSER_INPUT } from './fixtures';
* `contenteditable="false"` stretched it to the full line and pushed the
* surrounding text onto separate rows.
*/
test('a picked file mention becomes an inline token and sends as its path', async ({
test('the @ trigger: empty-menu sends, caret boundaries, trigger grammar, and the inline token round trip', async ({
invocableSkillsWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('@zzzznomatchzzzz');
await expect(page.getByRole('listbox', { name: '工作区文件' })).toBeVisible();

// A leaked send is asynchronous, so `toHaveCount(0)` here would pass before
// it lands — and a second Enter sending the same text would then hide it.
// Withhold, retype into something distinguishable, and pin the total.
await composer.press('Enter');
await composer.press('Enter');
await expect(page.getByText('Fake backend received: @zzzznomatchzzzz')).toBeVisible();
await expect(page.getByLabel('你发送的消息')).toHaveCount(1);
// Settle before the next phase sends: an Enter during a streaming turn
// becomes steering instead of a new message.
await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { timeout: 20_000 });

await composer.fill('看一下 @agent');
await expect(page.getByRole('listbox', { name: '工作区文件' })).toBeVisible();

for (let index = 0; index < 6; index += 1) await composer.press('ArrowLeft');
await expect.poll(() => composer.getAttribute('aria-expanded')).toBe('false');

await composer.press('Enter');
await expect(page.getByText('Fake backend received: 看一下 @agent')).toBeVisible();
await expect(composer.locator('[data-astryx-token]')).toHaveCount(0);
await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(2, { timeout: 20_000 });

const expanded = () => composer.getAttribute('aria-expanded');
const files = page.getByRole('listbox', { name: '工作区文件' });

await composer.fill('看一下 @agent');
await expect(files).toBeVisible();

// A space ends the query — narrowing an `@` search by a second word, which
// the retired popup allowed, is gone.
await composer.pressSequentially(' write');
await expect.poll(expanded).toBe('false');

// A non-boundary `@` is not a trigger.
await composer.fill('mail user@host.com');
await expect.poll(expanded).toBe('false');

// The nearest boundary wins.
await composer.fill('@a /b');
await expect(page.getByRole('listbox', { name: /技能/ })).toBeVisible();
await expect(files).toHaveCount(0);

await composer.fill('看一下 @agent');

const listbox = page.getByRole('listbox', { name: '工作区文件' });
Expand All@@ -33,7 +101,8 @@ test('a picked file mention becomes an inline token and sends as its path', asyn
expect(tokenWidth).toBeLessThan(lineWidth / 2);

await composer.press('Enter');
const bubble = page.getByLabel('你发送的消息').first();
// Third send of this journey: anchor on the newest bubble.
const bubble = page.getByLabel('你发送的消息').last();
await expect(bubble).toBeVisible();
const sentFileBadges = bubble.locator('.maka-chat-message-bubble-user .astryx-badge');
await expect(sentFileBadges).toHaveCount(1);
Expand All@@ -50,7 +119,7 @@ test('a picked file mention becomes an inline token and sends as its path', asyn
).toBeVisible();

await page.reload();
const reloadedBubble = page.getByLabel('你发送的消息').first();
const reloadedBubble = page.getByLabel('你发送的消息').last();
await expect(reloadedBubble).toBeVisible();
await expect(
reloadedBubble.locator('.maka-chat-message-bubble-user .astryx-badge'),
Expand All@@ -59,78 +128,3 @@ test('a picked file mention becomes an inline token and sends as its path', asyn
'普通文本 @.maka/skills/agent-write/SKILL.md',
);
});

/**
* Upstream contract: the trigger boundaries are Astryx's `findActiveTrigger`
* now, and it is NOT equivalent to the `detectMentionTrigger` it replaced — a
* space ends an `@` query, so a path with a space in it can no longer be
* searched. Pin the grammar we actually depend on so an Astryx upgrade that
* moves a boundary fails here rather than in front of a user.
*/
test('the trigger menu opens exactly on the boundaries we depend on', async ({
invocableSkillsWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
const expanded = () => composer.getAttribute('aria-expanded');
const files = page.getByRole('listbox', { name: '工作区文件' });

await composer.fill('看一下 @agent');
await expect(files).toBeVisible();

// A space ends the query — narrowing an `@` search by a second word, which
// the retired popup allowed, is gone.
await composer.pressSequentially(' write');
await expect.poll(expanded).toBe('false');

// A non-boundary `@` is not a trigger.
await composer.fill('mail user@host.com');
await expect.poll(expanded).toBe('false');

// The nearest boundary wins.
await composer.fill('@a /b');
await expect(page.getByRole('listbox', { name: /技能/ })).toBeVisible();
await expect(files).toHaveCount(0);
});

/**
* An open menu with nothing highlighted (still loading, or no matches) leaves
* Enter unconsumed. It must not send the draft out from under the popup — and
* it must not deadlock either: "no matches" is a stable state, so swallowing
* Enter forever would leave the keyboard unable to send at all.
*/
test('Enter with an open, empty trigger menu withholds one send, not every send', async ({
invocableSkillsWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('@zzzznomatchzzzz');
await expect(page.getByRole('listbox', { name: '工作区文件' })).toBeVisible();

// A leaked send is asynchronous, so `toHaveCount(0)` here would pass before
// it lands — and a second Enter sending the same text would then hide it.
// Withhold, retype into something distinguishable, and pin the total.
await composer.press('Enter');
await composer.press('Enter');
await expect(page.getByText('Fake backend received: @zzzznomatchzzzz')).toBeVisible();
await expect(page.getByLabel('你发送的消息')).toHaveCount(1);
});

/**
* The menu has to follow the caret, not just the text. Astryx recomputes the
* active trigger only on `input`, so an arrow key off the query used to leave
* the menu open over a trigger no longer under the cursor — and the next Enter
* spliced a token in at the stale offset instead of sending.
*/
test('moving the caret off the query closes the trigger menu', async ({
invocableSkillsWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('看一下 @agent');
await expect(page.getByRole('listbox', { name: '工作区文件' })).toBeVisible();

for (let index = 0; index < 6; index += 1) await composer.press('ArrowLeft');
await expect.poll(() => composer.getAttribute('aria-expanded')).toBe('false');

await composer.press('Enter');
await expect(page.getByText('Fake backend received: 看一下 @agent')).toBeVisible();
await expect(composer.locator('[data-astryx-token]')).toHaveCount(0);
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' test(desktop): merge same-fixture e2e assertions into coherent journeys by UncertaintyDeterminesYou4ndMe · Pull Request #2486 · apache/maka · GitHub
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 7 additions & 11 deletions apps/desktop/e2e/ask-user-question.spec.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
import { FAKE_ASK_USER_QUESTION_PROMPT } from '@maka/runtime';
import { test, expect, COMPOSER_INPUT } from './fixtures.js';

test('rehydrates a prompt the surface never received live', async ({ window: page }) => {
// One seeded prompt, one launch: the reload-rehydration contract and the
// answer flow are consecutive phases of the same parked turn. Answering
// *after* the reload is the stronger form of both tests — it proves the
// rehydrated prompt is not a rendering of lost state but the live turn.
test('rehydrates a prompt across reload, then answers all three questions in the same turn', async ({
window: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill(FAKE_ASK_USER_QUESTION_PROMPT);
await composer.press('Enter');
Expand All@@ -20,16 +26,6 @@ test('rehydrates a prompt the surface never received live', async ({ window: pag
.first()
.click();

await expect(prompt).toBeVisible();
await expect(prompt.getByText('1 / 3', { exact: true })).toBeVisible();
});

test('answers three questions and continues the same fake-backend turn', async ({ window: page }) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill(FAKE_ASK_USER_QUESTION_PROMPT);
await composer.press('Enter');

const prompt = page.locator('.maka-user-question-prompt');
await expect(prompt).toBeVisible();
await expect(page.locator('.maka-composer')).toBeHidden();
await expect(prompt.getByText('1 / 3', { exact: true })).toBeVisible();
Expand Down
58 changes: 27 additions & 31 deletions apps/desktop/e2e/attachment.spec.ts
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,12 @@
import { test, expect, COMPOSER_INPUT } from './fixtures';

test('chat input preserves an IME composition when a file paste arrives', async ({ window: page }) => {
const firstSend = page.locator(COMPOSER_INPUT);
await firstSend.fill('ime-paste-test');
await firstSend.press('Enter');
await expect(page.getByText(/Fake backend received: ime-paste-test/)).toBeVisible();

const composer = page.locator('.maka-composer[data-maka-file-drop-target="true"]');
const editable = composer.locator('[contenteditable="true"]');

const pasteResults = await editable.evaluate((input) => {
const dispatchFilePaste = () => {
const clipboardData = new DataTransfer();
clipboardData.items.add(new File(['content'], 'note.txt', { type: 'text/plain' }));
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', { value: clipboardData });
input.dispatchEvent(event);
return event.defaultPrevented;
};

input.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }));
const duringComposition = dispatchFilePaste();
input.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }));
const afterComposition = dispatchFilePaste();

return { duringComposition, afterComposition };
});

expect(pasteResults).toEqual({ duringComposition: false, afterComposition: true });
});

/**
* Attachment upload + ingest: enter the chat view, drop a file onto the main
* composer, confirm it shows as a pending token, then send the message and
* verify the fake backend received the attachments by name. Uses Playwright's
* DataTransfer + dispatchEvent because the composer has no <input type=file>.
*/
test('a mixed attachment send has the Astryx message hierarchy', async ({ window: page }) => {
test('a mixed attachment send has the Astryx message hierarchy, and IME composition survives a file paste', async ({ window: page }) => {
// Enter chat view by sending a first message from the composer, which
// creates the session on send.
const firstSend = page.locator(COMPOSER_INPUT);
Expand DownExpand Up@@ -152,4 +122,30 @@ test('a mixed attachment send has the Astryx message hierarchy', async ({ window

await page.keyboard.press('Escape');
await expect(page.locator('.astryx-lightbox')).not.toBeVisible();

// IME composition vs file paste, in the same window: pastes must not be
// intercepted mid-composition, and must be intercepted after it. Runs after
// the attachment journey because the post-composition paste stages a chip.
const editable = page
.locator('.maka-composer[data-maka-file-drop-target="true"]')
.locator('[contenteditable="true"]');
const pasteResults = await editable.evaluate((input) => {
const dispatchFilePaste = () => {
const clipboardData = new DataTransfer();
clipboardData.items.add(new File(['content'], 'note.txt', { type: 'text/plain' }));
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', { value: clipboardData });
input.dispatchEvent(event);
return event.defaultPrevented;
};

input.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }));
const duringComposition = dispatchFilePaste();
input.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }));
const afterComposition = dispatchFilePaste();

return { duringComposition, afterComposition };
});

expect(pasteResults).toEqual({ duringComposition: false, afterComposition: true });
});
9 changes: 4 additions & 5 deletions apps/desktop/e2e/bot-onboarding.spec.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
import { test, expect } from './fixtures';

test('IM 快捷接入完成真实 QR session、扫码状态和本机凭据落盘', async ({ botSettingsWindow: page }) => {
test('IM 快捷接入完成真实 QR session、凭据落盘,取消与过期二维码可恢复', async ({ botSettingsWindow: page }) => {
const settings = page.getByRole('main', { name: '设置内容' });
await expect(settings.getByRole('heading', { name: '远程接入' })).toBeVisible();

Expand DownExpand Up@@ -32,11 +32,10 @@ test('IM 快捷接入完成真实 QR session、扫码状态和本机凭据落盘

await dialog.getByRole('button', { name: '完成' }).click();
await expect(dialog).toBeHidden();
});

test('关闭扫码弹窗会取消迟到结果,过期二维码可以重新生成', async ({ botSettingsWindow: page }) => {
const settings = page.getByRole('main', { name: '设置内容' });

// Same window, next channels: cancellation races, expiry regeneration, and
// the Lark variant are independent flows over the same seeded settings.
await settings.getByRole('button', { name: '返回远程接入' }).click();
await settings.getByRole('button', { name: '接入 微信' }).click();
await settings.getByRole('button', { name: '扫码登录' }).click();
const wechatDialog = page.getByRole('dialog', { name: '微信扫码登录' });
Expand Down
150 changes: 72 additions & 78 deletions apps/desktop/e2e/composer-mention-token.spec.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,28 @@
import { expect, test, COMPOSER_INPUT } from './fixtures';

// One seeded window, four phases over the same composer. Order is dictated
// by the message-count pins: the empty-menu double-Enter phase asserts a
// total of exactly one sent message, so it runs first; the token phase sends
// last and anchors on the newest bubble.
/**
* An open menu with nothing highlighted (still loading, or no matches) leaves
* Enter unconsumed. It must not send the draft out from under the popup — and
* it must not deadlock either: "no matches" is a stable state, so swallowing
* Enter forever would leave the keyboard unable to send at all.
*/
/**
* The menu has to follow the caret, not just the text. Astryx recomputes the
* active trigger only on `input`, so an arrow key off the query used to leave
* the menu open over a trigger no longer under the cursor — and the next Enter
* spliced a token in at the stale offset instead of sending.
*/
/**
* Upstream contract: the trigger boundaries are Astryx's `findActiveTrigger`
* now, and it is NOT equivalent to the `detectMentionTrigger` it replaced — a
* space ends an `@` query, so a path with a space in it can no longer be
* searched. Pin the grammar we actually depend on so an Astryx upgrade that
* moves a boundary fails here rather than in front of a user.
*/
/**
* The `@` file trigger: menu → inline token → the path the backend receives.
* The token is a real chip in the draft now, so this also pins the one cascade
Expand All@@ -8,10 +31,55 @@ import { expect, test, COMPOSER_INPUT } from './fixtures';
* `contenteditable="false"` stretched it to the full line and pushed the
* surrounding text onto separate rows.
*/
test('a picked file mention becomes an inline token and sends as its path', async ({
test('the @ trigger: empty-menu sends, caret boundaries, trigger grammar, and the inline token round trip', async ({
invocableSkillsWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('@zzzznomatchzzzz');
await expect(page.getByRole('listbox', { name: '工作区文件' })).toBeVisible();

// A leaked send is asynchronous, so `toHaveCount(0)` here would pass before
// it lands — and a second Enter sending the same text would then hide it.
// Withhold, retype into something distinguishable, and pin the total.
await composer.press('Enter');
await composer.press('Enter');
await expect(page.getByText('Fake backend received: @zzzznomatchzzzz')).toBeVisible();
await expect(page.getByLabel('你发送的消息')).toHaveCount(1);
// Settle before the next phase sends: an Enter during a streaming turn
// becomes steering instead of a new message.
await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { timeout: 20_000 });

await composer.fill('看一下 @agent');
await expect(page.getByRole('listbox', { name: '工作区文件' })).toBeVisible();

for (let index = 0; index < 6; index += 1) await composer.press('ArrowLeft');
await expect.poll(() => composer.getAttribute('aria-expanded')).toBe('false');

await composer.press('Enter');
await expect(page.getByText('Fake backend received: 看一下 @agent')).toBeVisible();
await expect(composer.locator('[data-astryx-token]')).toHaveCount(0);
await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(2, { timeout: 20_000 });

const expanded = () => composer.getAttribute('aria-expanded');
const files = page.getByRole('listbox', { name: '工作区文件' });

await composer.fill('看一下 @agent');
await expect(files).toBeVisible();

// A space ends the query — narrowing an `@` search by a second word, which
// the retired popup allowed, is gone.
await composer.pressSequentially(' write');
await expect.poll(expanded).toBe('false');

// A non-boundary `@` is not a trigger.
await composer.fill('mail user@host.com');
await expect.poll(expanded).toBe('false');

// The nearest boundary wins.
await composer.fill('@a /b');
await expect(page.getByRole('listbox', { name: /技能/ })).toBeVisible();
await expect(files).toHaveCount(0);

await composer.fill('看一下 @agent');

const listbox = page.getByRole('listbox', { name: '工作区文件' });
Expand All@@ -33,7 +101,8 @@ test('a picked file mention becomes an inline token and sends as its path', asyn
expect(tokenWidth).toBeLessThan(lineWidth / 2);

await composer.press('Enter');
const bubble = page.getByLabel('你发送的消息').first();
// Third send of this journey: anchor on the newest bubble.
const bubble = page.getByLabel('你发送的消息').last();
await expect(bubble).toBeVisible();
const sentFileBadges = bubble.locator('.maka-chat-message-bubble-user .astryx-badge');
await expect(sentFileBadges).toHaveCount(1);
Expand All@@ -50,7 +119,7 @@ test('a picked file mention becomes an inline token and sends as its path', asyn
).toBeVisible();

await page.reload();
const reloadedBubble = page.getByLabel('你发送的消息').first();
const reloadedBubble = page.getByLabel('你发送的消息').last();
await expect(reloadedBubble).toBeVisible();
await expect(
reloadedBubble.locator('.maka-chat-message-bubble-user .astryx-badge'),
Expand All@@ -59,78 +128,3 @@ test('a picked file mention becomes an inline token and sends as its path', asyn
'普通文本 @.maka/skills/agent-write/SKILL.md',
);
});

/**
* Upstream contract: the trigger boundaries are Astryx's `findActiveTrigger`
* now, and it is NOT equivalent to the `detectMentionTrigger` it replaced — a
* space ends an `@` query, so a path with a space in it can no longer be
* searched. Pin the grammar we actually depend on so an Astryx upgrade that
* moves a boundary fails here rather than in front of a user.
*/
test('the trigger menu opens exactly on the boundaries we depend on', async ({
invocableSkillsWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
const expanded = () => composer.getAttribute('aria-expanded');
const files = page.getByRole('listbox', { name: '工作区文件' });

await composer.fill('看一下 @agent');
await expect(files).toBeVisible();

// A space ends the query — narrowing an `@` search by a second word, which
// the retired popup allowed, is gone.
await composer.pressSequentially(' write');
await expect.poll(expanded).toBe('false');

// A non-boundary `@` is not a trigger.
await composer.fill('mail user@host.com');
await expect.poll(expanded).toBe('false');

// The nearest boundary wins.
await composer.fill('@a /b');
await expect(page.getByRole('listbox', { name: /技能/ })).toBeVisible();
await expect(files).toHaveCount(0);
});

/**
* An open menu with nothing highlighted (still loading, or no matches) leaves
* Enter unconsumed. It must not send the draft out from under the popup — and
* it must not deadlock either: "no matches" is a stable state, so swallowing
* Enter forever would leave the keyboard unable to send at all.
*/
test('Enter with an open, empty trigger menu withholds one send, not every send', async ({
invocableSkillsWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('@zzzznomatchzzzz');
await expect(page.getByRole('listbox', { name: '工作区文件' })).toBeVisible();

// A leaked send is asynchronous, so `toHaveCount(0)` here would pass before
// it lands — and a second Enter sending the same text would then hide it.
// Withhold, retype into something distinguishable, and pin the total.
await composer.press('Enter');
await composer.press('Enter');
await expect(page.getByText('Fake backend received: @zzzznomatchzzzz')).toBeVisible();
await expect(page.getByLabel('你发送的消息')).toHaveCount(1);
});

/**
* The menu has to follow the caret, not just the text. Astryx recomputes the
* active trigger only on `input`, so an arrow key off the query used to leave
* the menu open over a trigger no longer under the cursor — and the next Enter
* spliced a token in at the stale offset instead of sending.
*/
test('moving the caret off the query closes the trigger menu', async ({
invocableSkillsWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('看一下 @agent');
await expect(page.getByRole('listbox', { name: '工作区文件' })).toBeVisible();

for (let index = 0; index < 6; index += 1) await composer.press('ArrowLeft');
await expect.poll(() => composer.getAttribute('aria-expanded')).toBe('false');

await composer.press('Enter');
await expect(page.getByText('Fake backend received: 看一下 @agent')).toBeVisible();
await expect(composer.locator('[data-astryx-token]')).toHaveCount(0);
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' test(desktop): merge same-fixture e2e assertions into coherent journeys by UncertaintyDeterminesYou4ndMe · Pull Request #2486 · apache/maka · GitHub
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 7 additions & 11 deletions apps/desktop/e2e/ask-user-question.spec.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
import { FAKE_ASK_USER_QUESTION_PROMPT } from '@maka/runtime';
import { test, expect, COMPOSER_INPUT } from './fixtures.js';

test('rehydrates a prompt the surface never received live', async ({ window: page }) => {
// One seeded prompt, one launch: the reload-rehydration contract and the
// answer flow are consecutive phases of the same parked turn. Answering
// *after* the reload is the stronger form of both tests — it proves the
// rehydrated prompt is not a rendering of lost state but the live turn.
test('rehydrates a prompt across reload, then answers all three questions in the same turn', async ({
window: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill(FAKE_ASK_USER_QUESTION_PROMPT);
await composer.press('Enter');
Expand All@@ -20,16 +26,6 @@ test('rehydrates a prompt the surface never received live', async ({ window: pag
.first()
.click();

await expect(prompt).toBeVisible();
await expect(prompt.getByText('1 / 3', { exact: true })).toBeVisible();
});

test('answers three questions and continues the same fake-backend turn', async ({ window: page }) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill(FAKE_ASK_USER_QUESTION_PROMPT);
await composer.press('Enter');

const prompt = page.locator('.maka-user-question-prompt');
await expect(prompt).toBeVisible();
await expect(page.locator('.maka-composer')).toBeHidden();
await expect(prompt.getByText('1 / 3', { exact: true })).toBeVisible();
Expand Down
58 changes: 27 additions & 31 deletions apps/desktop/e2e/attachment.spec.ts
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,12 @@
import { test, expect, COMPOSER_INPUT } from './fixtures';

test('chat input preserves an IME composition when a file paste arrives', async ({ window: page }) => {
const firstSend = page.locator(COMPOSER_INPUT);
await firstSend.fill('ime-paste-test');
await firstSend.press('Enter');
await expect(page.getByText(/Fake backend received: ime-paste-test/)).toBeVisible();

const composer = page.locator('.maka-composer[data-maka-file-drop-target="true"]');
const editable = composer.locator('[contenteditable="true"]');

const pasteResults = await editable.evaluate((input) => {
const dispatchFilePaste = () => {
const clipboardData = new DataTransfer();
clipboardData.items.add(new File(['content'], 'note.txt', { type: 'text/plain' }));
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', { value: clipboardData });
input.dispatchEvent(event);
return event.defaultPrevented;
};

input.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }));
const duringComposition = dispatchFilePaste();
input.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }));
const afterComposition = dispatchFilePaste();

return { duringComposition, afterComposition };
});

expect(pasteResults).toEqual({ duringComposition: false, afterComposition: true });
});

/**
* Attachment upload + ingest: enter the chat view, drop a file onto the main
* composer, confirm it shows as a pending token, then send the message and
* verify the fake backend received the attachments by name. Uses Playwright's
* DataTransfer + dispatchEvent because the composer has no <input type=file>.
*/
test('a mixed attachment send has the Astryx message hierarchy', async ({ window: page }) => {
test('a mixed attachment send has the Astryx message hierarchy, and IME composition survives a file paste', async ({ window: page }) => {
// Enter chat view by sending a first message from the composer, which
// creates the session on send.
const firstSend = page.locator(COMPOSER_INPUT);
Expand DownExpand Up@@ -152,4 +122,30 @@ test('a mixed attachment send has the Astryx message hierarchy', async ({ window

await page.keyboard.press('Escape');
await expect(page.locator('.astryx-lightbox')).not.toBeVisible();

// IME composition vs file paste, in the same window: pastes must not be
// intercepted mid-composition, and must be intercepted after it. Runs after
// the attachment journey because the post-composition paste stages a chip.
const editable = page
.locator('.maka-composer[data-maka-file-drop-target="true"]')
.locator('[contenteditable="true"]');
const pasteResults = await editable.evaluate((input) => {
const dispatchFilePaste = () => {
const clipboardData = new DataTransfer();
clipboardData.items.add(new File(['content'], 'note.txt', { type: 'text/plain' }));
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', { value: clipboardData });
input.dispatchEvent(event);
return event.defaultPrevented;
};

input.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }));
const duringComposition = dispatchFilePaste();
input.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }));
const afterComposition = dispatchFilePaste();

return { duringComposition, afterComposition };
});

expect(pasteResults).toEqual({ duringComposition: false, afterComposition: true });
});
9 changes: 4 additions & 5 deletions apps/desktop/e2e/bot-onboarding.spec.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
import { test, expect } from './fixtures';

test('IM 快捷接入完成真实 QR session、扫码状态和本机凭据落盘', async ({ botSettingsWindow: page }) => {
test('IM 快捷接入完成真实 QR session、凭据落盘,取消与过期二维码可恢复', async ({ botSettingsWindow: page }) => {
const settings = page.getByRole('main', { name: '设置内容' });
await expect(settings.getByRole('heading', { name: '远程接入' })).toBeVisible();

Expand DownExpand Up@@ -32,11 +32,10 @@ test('IM 快捷接入完成真实 QR session、扫码状态和本机凭据落盘

await dialog.getByRole('button', { name: '完成' }).click();
await expect(dialog).toBeHidden();
});

test('关闭扫码弹窗会取消迟到结果,过期二维码可以重新生成', async ({ botSettingsWindow: page }) => {
const settings = page.getByRole('main', { name: '设置内容' });

// Same window, next channels: cancellation races, expiry regeneration, and
// the Lark variant are independent flows over the same seeded settings.
await settings.getByRole('button', { name: '返回远程接入' }).click();
await settings.getByRole('button', { name: '接入 微信' }).click();
await settings.getByRole('button', { name: '扫码登录' }).click();
const wechatDialog = page.getByRole('dialog', { name: '微信扫码登录' });
Expand Down
150 changes: 72 additions & 78 deletions apps/desktop/e2e/composer-mention-token.spec.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,28 @@
import { expect, test, COMPOSER_INPUT } from './fixtures';

// One seeded window, four phases over the same composer. Order is dictated
// by the message-count pins: the empty-menu double-Enter phase asserts a
// total of exactly one sent message, so it runs first; the token phase sends
// last and anchors on the newest bubble.
/**
* An open menu with nothing highlighted (still loading, or no matches) leaves
* Enter unconsumed. It must not send the draft out from under the popup — and
* it must not deadlock either: "no matches" is a stable state, so swallowing
* Enter forever would leave the keyboard unable to send at all.
*/
/**
* The menu has to follow the caret, not just the text. Astryx recomputes the
* active trigger only on `input`, so an arrow key off the query used to leave
* the menu open over a trigger no longer under the cursor — and the next Enter
* spliced a token in at the stale offset instead of sending.
*/
/**
* Upstream contract: the trigger boundaries are Astryx's `findActiveTrigger`
* now, and it is NOT equivalent to the `detectMentionTrigger` it replaced — a
* space ends an `@` query, so a path with a space in it can no longer be
* searched. Pin the grammar we actually depend on so an Astryx upgrade that
* moves a boundary fails here rather than in front of a user.
*/
/**
* The `@` file trigger: menu → inline token → the path the backend receives.
* The token is a real chip in the draft now, so this also pins the one cascade
Expand All@@ -8,10 +31,55 @@ import { expect, test, COMPOSER_INPUT } from './fixtures';
* `contenteditable="false"` stretched it to the full line and pushed the
* surrounding text onto separate rows.
*/
test('a picked file mention becomes an inline token and sends as its path', async ({
test('the @ trigger: empty-menu sends, caret boundaries, trigger grammar, and the inline token round trip', async ({
invocableSkillsWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('@zzzznomatchzzzz');
await expect(page.getByRole('listbox', { name: '工作区文件' })).toBeVisible();

// A leaked send is asynchronous, so `toHaveCount(0)` here would pass before
// it lands — and a second Enter sending the same text would then hide it.
// Withhold, retype into something distinguishable, and pin the total.
await composer.press('Enter');
await composer.press('Enter');
await expect(page.getByText('Fake backend received: @zzzznomatchzzzz')).toBeVisible();
await expect(page.getByLabel('你发送的消息')).toHaveCount(1);
// Settle before the next phase sends: an Enter during a streaming turn
// becomes steering instead of a new message.
await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { timeout: 20_000 });

await composer.fill('看一下 @agent');
await expect(page.getByRole('listbox', { name: '工作区文件' })).toBeVisible();

for (let index = 0; index < 6; index += 1) await composer.press('ArrowLeft');
await expect.poll(() => composer.getAttribute('aria-expanded')).toBe('false');

await composer.press('Enter');
await expect(page.getByText('Fake backend received: 看一下 @agent')).toBeVisible();
await expect(composer.locator('[data-astryx-token]')).toHaveCount(0);
await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(2, { timeout: 20_000 });

const expanded = () => composer.getAttribute('aria-expanded');
const files = page.getByRole('listbox', { name: '工作区文件' });

await composer.fill('看一下 @agent');
await expect(files).toBeVisible();

// A space ends the query — narrowing an `@` search by a second word, which
// the retired popup allowed, is gone.
await composer.pressSequentially(' write');
await expect.poll(expanded).toBe('false');

// A non-boundary `@` is not a trigger.
await composer.fill('mail user@host.com');
await expect.poll(expanded).toBe('false');

// The nearest boundary wins.
await composer.fill('@a /b');
await expect(page.getByRole('listbox', { name: /技能/ })).toBeVisible();
await expect(files).toHaveCount(0);

await composer.fill('看一下 @agent');

const listbox = page.getByRole('listbox', { name: '工作区文件' });
Expand All@@ -33,7 +101,8 @@ test('a picked file mention becomes an inline token and sends as its path', asyn
expect(tokenWidth).toBeLessThan(lineWidth / 2);

await composer.press('Enter');
const bubble = page.getByLabel('你发送的消息').first();
// Third send of this journey: anchor on the newest bubble.
const bubble = page.getByLabel('你发送的消息').last();
await expect(bubble).toBeVisible();
const sentFileBadges = bubble.locator('.maka-chat-message-bubble-user .astryx-badge');
await expect(sentFileBadges).toHaveCount(1);
Expand All@@ -50,7 +119,7 @@ test('a picked file mention becomes an inline token and sends as its path', asyn
).toBeVisible();

await page.reload();
const reloadedBubble = page.getByLabel('你发送的消息').first();
const reloadedBubble = page.getByLabel('你发送的消息').last();
await expect(reloadedBubble).toBeVisible();
await expect(
reloadedBubble.locator('.maka-chat-message-bubble-user .astryx-badge'),
Expand All@@ -59,78 +128,3 @@ test('a picked file mention becomes an inline token and sends as its path', asyn
'普通文本 @.maka/skills/agent-write/SKILL.md',
);
});

/**
* Upstream contract: the trigger boundaries are Astryx's `findActiveTrigger`
* now, and it is NOT equivalent to the `detectMentionTrigger` it replaced — a
* space ends an `@` query, so a path with a space in it can no longer be
* searched. Pin the grammar we actually depend on so an Astryx upgrade that
* moves a boundary fails here rather than in front of a user.
*/
test('the trigger menu opens exactly on the boundaries we depend on', async ({
invocableSkillsWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
const expanded = () => composer.getAttribute('aria-expanded');
const files = page.getByRole('listbox', { name: '工作区文件' });

await composer.fill('看一下 @agent');
await expect(files).toBeVisible();

// A space ends the query — narrowing an `@` search by a second word, which
// the retired popup allowed, is gone.
await composer.pressSequentially(' write');
await expect.poll(expanded).toBe('false');

// A non-boundary `@` is not a trigger.
await composer.fill('mail user@host.com');
await expect.poll(expanded).toBe('false');

// The nearest boundary wins.
await composer.fill('@a /b');
await expect(page.getByRole('listbox', { name: /技能/ })).toBeVisible();
await expect(files).toHaveCount(0);
});

/**
* An open menu with nothing highlighted (still loading, or no matches) leaves
* Enter unconsumed. It must not send the draft out from under the popup — and
* it must not deadlock either: "no matches" is a stable state, so swallowing
* Enter forever would leave the keyboard unable to send at all.
*/
test('Enter with an open, empty trigger menu withholds one send, not every send', async ({
invocableSkillsWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('@zzzznomatchzzzz');
await expect(page.getByRole('listbox', { name: '工作区文件' })).toBeVisible();

// A leaked send is asynchronous, so `toHaveCount(0)` here would pass before
// it lands — and a second Enter sending the same text would then hide it.
// Withhold, retype into something distinguishable, and pin the total.
await composer.press('Enter');
await composer.press('Enter');
await expect(page.getByText('Fake backend received: @zzzznomatchzzzz')).toBeVisible();
await expect(page.getByLabel('你发送的消息')).toHaveCount(1);
});

/**
* The menu has to follow the caret, not just the text. Astryx recomputes the
* active trigger only on `input`, so an arrow key off the query used to leave
* the menu open over a trigger no longer under the cursor — and the next Enter
* spliced a token in at the stale offset instead of sending.
*/
test('moving the caret off the query closes the trigger menu', async ({
invocableSkillsWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('看一下 @agent');
await expect(page.getByRole('listbox', { name: '工作区文件' })).toBeVisible();

for (let index = 0; index < 6; index += 1) await composer.press('ArrowLeft');
await expect.poll(() => composer.getAttribute('aria-expanded')).toBe('false');

await composer.press('Enter');
await expect(page.getByText('Fake backend received: 看一下 @agent')).toBeVisible();
await expect(composer.locator('[data-astryx-token]')).toHaveCount(0);
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' test(desktop): merge same-fixture e2e assertions into coherent journeys by UncertaintyDeterminesYou4ndMe · Pull Request #2486 · apache/maka · GitHub
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 7 additions & 11 deletions apps/desktop/e2e/ask-user-question.spec.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
import { FAKE_ASK_USER_QUESTION_PROMPT } from '@maka/runtime';
import { test, expect, COMPOSER_INPUT } from './fixtures.js';

test('rehydrates a prompt the surface never received live', async ({ window: page }) => {
// One seeded prompt, one launch: the reload-rehydration contract and the
// answer flow are consecutive phases of the same parked turn. Answering
// *after* the reload is the stronger form of both tests — it proves the
// rehydrated prompt is not a rendering of lost state but the live turn.
test('rehydrates a prompt across reload, then answers all three questions in the same turn', async ({
window: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill(FAKE_ASK_USER_QUESTION_PROMPT);
await composer.press('Enter');
Expand All@@ -20,16 +26,6 @@ test('rehydrates a prompt the surface never received live', async ({ window: pag
.first()
.click();

await expect(prompt).toBeVisible();
await expect(prompt.getByText('1 / 3', { exact: true })).toBeVisible();
});

test('answers three questions and continues the same fake-backend turn', async ({ window: page }) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill(FAKE_ASK_USER_QUESTION_PROMPT);
await composer.press('Enter');

const prompt = page.locator('.maka-user-question-prompt');
await expect(prompt).toBeVisible();
await expect(page.locator('.maka-composer')).toBeHidden();
await expect(prompt.getByText('1 / 3', { exact: true })).toBeVisible();
Expand Down
58 changes: 27 additions & 31 deletions apps/desktop/e2e/attachment.spec.ts
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,12 @@
import { test, expect, COMPOSER_INPUT } from './fixtures';

test('chat input preserves an IME composition when a file paste arrives', async ({ window: page }) => {
const firstSend = page.locator(COMPOSER_INPUT);
await firstSend.fill('ime-paste-test');
await firstSend.press('Enter');
await expect(page.getByText(/Fake backend received: ime-paste-test/)).toBeVisible();

const composer = page.locator('.maka-composer[data-maka-file-drop-target="true"]');
const editable = composer.locator('[contenteditable="true"]');

const pasteResults = await editable.evaluate((input) => {
const dispatchFilePaste = () => {
const clipboardData = new DataTransfer();
clipboardData.items.add(new File(['content'], 'note.txt', { type: 'text/plain' }));
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', { value: clipboardData });
input.dispatchEvent(event);
return event.defaultPrevented;
};

input.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }));
const duringComposition = dispatchFilePaste();
input.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }));
const afterComposition = dispatchFilePaste();

return { duringComposition, afterComposition };
});

expect(pasteResults).toEqual({ duringComposition: false, afterComposition: true });
});

/**
* Attachment upload + ingest: enter the chat view, drop a file onto the main
* composer, confirm it shows as a pending token, then send the message and
* verify the fake backend received the attachments by name. Uses Playwright's
* DataTransfer + dispatchEvent because the composer has no <input type=file>.
*/
test('a mixed attachment send has the Astryx message hierarchy', async ({ window: page }) => {
test('a mixed attachment send has the Astryx message hierarchy, and IME composition survives a file paste', async ({ window: page }) => {
// Enter chat view by sending a first message from the composer, which
// creates the session on send.
const firstSend = page.locator(COMPOSER_INPUT);
Expand DownExpand Up@@ -152,4 +122,30 @@ test('a mixed attachment send has the Astryx message hierarchy', async ({ window

await page.keyboard.press('Escape');
await expect(page.locator('.astryx-lightbox')).not.toBeVisible();

// IME composition vs file paste, in the same window: pastes must not be
// intercepted mid-composition, and must be intercepted after it. Runs after
// the attachment journey because the post-composition paste stages a chip.
const editable = page
.locator('.maka-composer[data-maka-file-drop-target="true"]')
.locator('[contenteditable="true"]');
const pasteResults = await editable.evaluate((input) => {
const dispatchFilePaste = () => {
const clipboardData = new DataTransfer();
clipboardData.items.add(new File(['content'], 'note.txt', { type: 'text/plain' }));
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', { value: clipboardData });
input.dispatchEvent(event);
return event.defaultPrevented;
};

input.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }));
const duringComposition = dispatchFilePaste();
input.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }));
const afterComposition = dispatchFilePaste();

return { duringComposition, afterComposition };
});

expect(pasteResults).toEqual({ duringComposition: false, afterComposition: true });
});
9 changes: 4 additions & 5 deletions apps/desktop/e2e/bot-onboarding.spec.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
import { test, expect } from './fixtures';

test('IM 快捷接入完成真实 QR session、扫码状态和本机凭据落盘', async ({ botSettingsWindow: page }) => {
test('IM 快捷接入完成真实 QR session、凭据落盘,取消与过期二维码可恢复', async ({ botSettingsWindow: page }) => {
const settings = page.getByRole('main', { name: '设置内容' });
await expect(settings.getByRole('heading', { name: '远程接入' })).toBeVisible();

Expand DownExpand Up@@ -32,11 +32,10 @@ test('IM 快捷接入完成真实 QR session、扫码状态和本机凭据落盘

await dialog.getByRole('button', { name: '完成' }).click();
await expect(dialog).toBeHidden();
});

test('关闭扫码弹窗会取消迟到结果,过期二维码可以重新生成', async ({ botSettingsWindow: page }) => {
const settings = page.getByRole('main', { name: '设置内容' });

// Same window, next channels: cancellation races, expiry regeneration, and
// the Lark variant are independent flows over the same seeded settings.
await settings.getByRole('button', { name: '返回远程接入' }).click();
await settings.getByRole('button', { name: '接入 微信' }).click();
await settings.getByRole('button', { name: '扫码登录' }).click();
const wechatDialog = page.getByRole('dialog', { name: '微信扫码登录' });
Expand Down
150 changes: 72 additions & 78 deletions apps/desktop/e2e/composer-mention-token.spec.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,28 @@
import { expect, test, COMPOSER_INPUT } from './fixtures';

// One seeded window, four phases over the same composer. Order is dictated
// by the message-count pins: the empty-menu double-Enter phase asserts a
// total of exactly one sent message, so it runs first; the token phase sends
// last and anchors on the newest bubble.
/**
* An open menu with nothing highlighted (still loading, or no matches) leaves
* Enter unconsumed. It must not send the draft out from under the popup — and
* it must not deadlock either: "no matches" is a stable state, so swallowing
* Enter forever would leave the keyboard unable to send at all.
*/
/**
* The menu has to follow the caret, not just the text. Astryx recomputes the
* active trigger only on `input`, so an arrow key off the query used to leave
* the menu open over a trigger no longer under the cursor — and the next Enter
* spliced a token in at the stale offset instead of sending.
*/
/**
* Upstream contract: the trigger boundaries are Astryx's `findActiveTrigger`
* now, and it is NOT equivalent to the `detectMentionTrigger` it replaced — a
* space ends an `@` query, so a path with a space in it can no longer be
* searched. Pin the grammar we actually depend on so an Astryx upgrade that
* moves a boundary fails here rather than in front of a user.
*/
/**
* The `@` file trigger: menu → inline token → the path the backend receives.
* The token is a real chip in the draft now, so this also pins the one cascade
Expand All@@ -8,10 +31,55 @@ import { expect, test, COMPOSER_INPUT } from './fixtures';
* `contenteditable="false"` stretched it to the full line and pushed the
* surrounding text onto separate rows.
*/
test('a picked file mention becomes an inline token and sends as its path', async ({
test('the @ trigger: empty-menu sends, caret boundaries, trigger grammar, and the inline token round trip', async ({
invocableSkillsWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('@zzzznomatchzzzz');
await expect(page.getByRole('listbox', { name: '工作区文件' })).toBeVisible();

// A leaked send is asynchronous, so `toHaveCount(0)` here would pass before
// it lands — and a second Enter sending the same text would then hide it.
// Withhold, retype into something distinguishable, and pin the total.
await composer.press('Enter');
await composer.press('Enter');
await expect(page.getByText('Fake backend received: @zzzznomatchzzzz')).toBeVisible();
await expect(page.getByLabel('你发送的消息')).toHaveCount(1);
// Settle before the next phase sends: an Enter during a streaming turn
// becomes steering instead of a new message.
await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { timeout: 20_000 });

await composer.fill('看一下 @agent');
await expect(page.getByRole('listbox', { name: '工作区文件' })).toBeVisible();

for (let index = 0; index < 6; index += 1) await composer.press('ArrowLeft');
await expect.poll(() => composer.getAttribute('aria-expanded')).toBe('false');

await composer.press('Enter');
await expect(page.getByText('Fake backend received: 看一下 @agent')).toBeVisible();
await expect(composer.locator('[data-astryx-token]')).toHaveCount(0);
await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(2, { timeout: 20_000 });

const expanded = () => composer.getAttribute('aria-expanded');
const files = page.getByRole('listbox', { name: '工作区文件' });

await composer.fill('看一下 @agent');
await expect(files).toBeVisible();

// A space ends the query — narrowing an `@` search by a second word, which
// the retired popup allowed, is gone.
await composer.pressSequentially(' write');
await expect.poll(expanded).toBe('false');

// A non-boundary `@` is not a trigger.
await composer.fill('mail user@host.com');
await expect.poll(expanded).toBe('false');

// The nearest boundary wins.
await composer.fill('@a /b');
await expect(page.getByRole('listbox', { name: /技能/ })).toBeVisible();
await expect(files).toHaveCount(0);

await composer.fill('看一下 @agent');

const listbox = page.getByRole('listbox', { name: '工作区文件' });
Expand All@@ -33,7 +101,8 @@ test('a picked file mention becomes an inline token and sends as its path', asyn
expect(tokenWidth).toBeLessThan(lineWidth / 2);

await composer.press('Enter');
const bubble = page.getByLabel('你发送的消息').first();
// Third send of this journey: anchor on the newest bubble.
const bubble = page.getByLabel('你发送的消息').last();
await expect(bubble).toBeVisible();
const sentFileBadges = bubble.locator('.maka-chat-message-bubble-user .astryx-badge');
await expect(sentFileBadges).toHaveCount(1);
Expand All@@ -50,7 +119,7 @@ test('a picked file mention becomes an inline token and sends as its path', asyn
).toBeVisible();

await page.reload();
const reloadedBubble = page.getByLabel('你发送的消息').first();
const reloadedBubble = page.getByLabel('你发送的消息').last();
await expect(reloadedBubble).toBeVisible();
await expect(
reloadedBubble.locator('.maka-chat-message-bubble-user .astryx-badge'),
Expand All@@ -59,78 +128,3 @@ test('a picked file mention becomes an inline token and sends as its path', asyn
'普通文本 @.maka/skills/agent-write/SKILL.md',
);
});

/**
* Upstream contract: the trigger boundaries are Astryx's `findActiveTrigger`
* now, and it is NOT equivalent to the `detectMentionTrigger` it replaced — a
* space ends an `@` query, so a path with a space in it can no longer be
* searched. Pin the grammar we actually depend on so an Astryx upgrade that
* moves a boundary fails here rather than in front of a user.
*/
test('the trigger menu opens exactly on the boundaries we depend on', async ({
invocableSkillsWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
const expanded = () => composer.getAttribute('aria-expanded');
const files = page.getByRole('listbox', { name: '工作区文件' });

await composer.fill('看一下 @agent');
await expect(files).toBeVisible();

// A space ends the query — narrowing an `@` search by a second word, which
// the retired popup allowed, is gone.
await composer.pressSequentially(' write');
await expect.poll(expanded).toBe('false');

// A non-boundary `@` is not a trigger.
await composer.fill('mail user@host.com');
await expect.poll(expanded).toBe('false');

// The nearest boundary wins.
await composer.fill('@a /b');
await expect(page.getByRole('listbox', { name: /技能/ })).toBeVisible();
await expect(files).toHaveCount(0);
});

/**
* An open menu with nothing highlighted (still loading, or no matches) leaves
* Enter unconsumed. It must not send the draft out from under the popup — and
* it must not deadlock either: "no matches" is a stable state, so swallowing
* Enter forever would leave the keyboard unable to send at all.
*/
test('Enter with an open, empty trigger menu withholds one send, not every send', async ({
invocableSkillsWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('@zzzznomatchzzzz');
await expect(page.getByRole('listbox', { name: '工作区文件' })).toBeVisible();

// A leaked send is asynchronous, so `toHaveCount(0)` here would pass before
// it lands — and a second Enter sending the same text would then hide it.
// Withhold, retype into something distinguishable, and pin the total.
await composer.press('Enter');
await composer.press('Enter');
await expect(page.getByText('Fake backend received: @zzzznomatchzzzz')).toBeVisible();
await expect(page.getByLabel('你发送的消息')).toHaveCount(1);
});

/**
* The menu has to follow the caret, not just the text. Astryx recomputes the
* active trigger only on `input`, so an arrow key off the query used to leave
* the menu open over a trigger no longer under the cursor — and the next Enter
* spliced a token in at the stale offset instead of sending.
*/
test('moving the caret off the query closes the trigger menu', async ({
invocableSkillsWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('看一下 @agent');
await expect(page.getByRole('listbox', { name: '工作区文件' })).toBeVisible();

for (let index = 0; index < 6; index += 1) await composer.press('ArrowLeft');
await expect.poll(() => composer.getAttribute('aria-expanded')).toBe('false');

await composer.press('Enter');
await expect(page.getByText('Fake backend received: 看一下 @agent')).toBeVisible();
await expect(composer.locator('[data-astryx-token]')).toHaveCount(0);
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' test(desktop): merge same-fixture e2e assertions into coherent journeys by UncertaintyDeterminesYou4ndMe · Pull Request #2486 · apache/maka · GitHub
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 7 additions & 11 deletions apps/desktop/e2e/ask-user-question.spec.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
import { FAKE_ASK_USER_QUESTION_PROMPT } from '@maka/runtime';
import { test, expect, COMPOSER_INPUT } from './fixtures.js';

test('rehydrates a prompt the surface never received live', async ({ window: page }) => {
// One seeded prompt, one launch: the reload-rehydration contract and the
// answer flow are consecutive phases of the same parked turn. Answering
// *after* the reload is the stronger form of both tests — it proves the
// rehydrated prompt is not a rendering of lost state but the live turn.
test('rehydrates a prompt across reload, then answers all three questions in the same turn', async ({
window: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill(FAKE_ASK_USER_QUESTION_PROMPT);
await composer.press('Enter');
Expand All@@ -20,16 +26,6 @@ test('rehydrates a prompt the surface never received live', async ({ window: pag
.first()
.click();

await expect(prompt).toBeVisible();
await expect(prompt.getByText('1 / 3', { exact: true })).toBeVisible();
});

test('answers three questions and continues the same fake-backend turn', async ({ window: page }) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill(FAKE_ASK_USER_QUESTION_PROMPT);
await composer.press('Enter');

const prompt = page.locator('.maka-user-question-prompt');
await expect(prompt).toBeVisible();
await expect(page.locator('.maka-composer')).toBeHidden();
await expect(prompt.getByText('1 / 3', { exact: true })).toBeVisible();
Expand Down
58 changes: 27 additions & 31 deletions apps/desktop/e2e/attachment.spec.ts
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,12 @@
import { test, expect, COMPOSER_INPUT } from './fixtures';

test('chat input preserves an IME composition when a file paste arrives', async ({ window: page }) => {
const firstSend = page.locator(COMPOSER_INPUT);
await firstSend.fill('ime-paste-test');
await firstSend.press('Enter');
await expect(page.getByText(/Fake backend received: ime-paste-test/)).toBeVisible();

const composer = page.locator('.maka-composer[data-maka-file-drop-target="true"]');
const editable = composer.locator('[contenteditable="true"]');

const pasteResults = await editable.evaluate((input) => {
const dispatchFilePaste = () => {
const clipboardData = new DataTransfer();
clipboardData.items.add(new File(['content'], 'note.txt', { type: 'text/plain' }));
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', { value: clipboardData });
input.dispatchEvent(event);
return event.defaultPrevented;
};

input.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }));
const duringComposition = dispatchFilePaste();
input.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }));
const afterComposition = dispatchFilePaste();

return { duringComposition, afterComposition };
});

expect(pasteResults).toEqual({ duringComposition: false, afterComposition: true });
});

/**
* Attachment upload + ingest: enter the chat view, drop a file onto the main
* composer, confirm it shows as a pending token, then send the message and
* verify the fake backend received the attachments by name. Uses Playwright's
* DataTransfer + dispatchEvent because the composer has no <input type=file>.
*/
test('a mixed attachment send has the Astryx message hierarchy', async ({ window: page }) => {
test('a mixed attachment send has the Astryx message hierarchy, and IME composition survives a file paste', async ({ window: page }) => {
// Enter chat view by sending a first message from the composer, which
// creates the session on send.
const firstSend = page.locator(COMPOSER_INPUT);
Expand DownExpand Up@@ -152,4 +122,30 @@ test('a mixed attachment send has the Astryx message hierarchy', async ({ window

await page.keyboard.press('Escape');
await expect(page.locator('.astryx-lightbox')).not.toBeVisible();

// IME composition vs file paste, in the same window: pastes must not be
// intercepted mid-composition, and must be intercepted after it. Runs after
// the attachment journey because the post-composition paste stages a chip.
const editable = page
.locator('.maka-composer[data-maka-file-drop-target="true"]')
.locator('[contenteditable="true"]');
const pasteResults = await editable.evaluate((input) => {
const dispatchFilePaste = () => {
const clipboardData = new DataTransfer();
clipboardData.items.add(new File(['content'], 'note.txt', { type: 'text/plain' }));
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', { value: clipboardData });
input.dispatchEvent(event);
return event.defaultPrevented;
};

input.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }));
const duringComposition = dispatchFilePaste();
input.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }));
const afterComposition = dispatchFilePaste();

return { duringComposition, afterComposition };
});

expect(pasteResults).toEqual({ duringComposition: false, afterComposition: true });
});
9 changes: 4 additions & 5 deletions apps/desktop/e2e/bot-onboarding.spec.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
import { test, expect } from './fixtures';

test('IM 快捷接入完成真实 QR session、扫码状态和本机凭据落盘', async ({ botSettingsWindow: page }) => {
test('IM 快捷接入完成真实 QR session、凭据落盘,取消与过期二维码可恢复', async ({ botSettingsWindow: page }) => {
const settings = page.getByRole('main', { name: '设置内容' });
await expect(settings.getByRole('heading', { name: '远程接入' })).toBeVisible();

Expand DownExpand Up@@ -32,11 +32,10 @@ test('IM 快捷接入完成真实 QR session、扫码状态和本机凭据落盘

await dialog.getByRole('button', { name: '完成' }).click();
await expect(dialog).toBeHidden();
});

test('关闭扫码弹窗会取消迟到结果,过期二维码可以重新生成', async ({ botSettingsWindow: page }) => {
const settings = page.getByRole('main', { name: '设置内容' });

// Same window, next channels: cancellation races, expiry regeneration, and
// the Lark variant are independent flows over the same seeded settings.
await settings.getByRole('button', { name: '返回远程接入' }).click();
await settings.getByRole('button', { name: '接入 微信' }).click();
await settings.getByRole('button', { name: '扫码登录' }).click();
const wechatDialog = page.getByRole('dialog', { name: '微信扫码登录' });
Expand Down
150 changes: 72 additions & 78 deletions apps/desktop/e2e/composer-mention-token.spec.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,28 @@
import { expect, test, COMPOSER_INPUT } from './fixtures';

// One seeded window, four phases over the same composer. Order is dictated
// by the message-count pins: the empty-menu double-Enter phase asserts a
// total of exactly one sent message, so it runs first; the token phase sends
// last and anchors on the newest bubble.
/**
* An open menu with nothing highlighted (still loading, or no matches) leaves
* Enter unconsumed. It must not send the draft out from under the popup — and
* it must not deadlock either: "no matches" is a stable state, so swallowing
* Enter forever would leave the keyboard unable to send at all.
*/
/**
* The menu has to follow the caret, not just the text. Astryx recomputes the
* active trigger only on `input`, so an arrow key off the query used to leave
* the menu open over a trigger no longer under the cursor — and the next Enter
* spliced a token in at the stale offset instead of sending.
*/
/**
* Upstream contract: the trigger boundaries are Astryx's `findActiveTrigger`
* now, and it is NOT equivalent to the `detectMentionTrigger` it replaced — a
* space ends an `@` query, so a path with a space in it can no longer be
* searched. Pin the grammar we actually depend on so an Astryx upgrade that
* moves a boundary fails here rather than in front of a user.
*/
/**
* The `@` file trigger: menu → inline token → the path the backend receives.
* The token is a real chip in the draft now, so this also pins the one cascade
Expand All@@ -8,10 +31,55 @@ import { expect, test, COMPOSER_INPUT } from './fixtures';
* `contenteditable="false"` stretched it to the full line and pushed the
* surrounding text onto separate rows.
*/
test('a picked file mention becomes an inline token and sends as its path', async ({
test('the @ trigger: empty-menu sends, caret boundaries, trigger grammar, and the inline token round trip', async ({
invocableSkillsWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('@zzzznomatchzzzz');
await expect(page.getByRole('listbox', { name: '工作区文件' })).toBeVisible();

// A leaked send is asynchronous, so `toHaveCount(0)` here would pass before
// it lands — and a second Enter sending the same text would then hide it.
// Withhold, retype into something distinguishable, and pin the total.
await composer.press('Enter');
await composer.press('Enter');
await expect(page.getByText('Fake backend received: @zzzznomatchzzzz')).toBeVisible();
await expect(page.getByLabel('你发送的消息')).toHaveCount(1);
// Settle before the next phase sends: an Enter during a streaming turn
// becomes steering instead of a new message.
await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { timeout: 20_000 });

await composer.fill('看一下 @agent');
await expect(page.getByRole('listbox', { name: '工作区文件' })).toBeVisible();

for (let index = 0; index < 6; index += 1) await composer.press('ArrowLeft');
await expect.poll(() => composer.getAttribute('aria-expanded')).toBe('false');

await composer.press('Enter');
await expect(page.getByText('Fake backend received: 看一下 @agent')).toBeVisible();
await expect(composer.locator('[data-astryx-token]')).toHaveCount(0);
await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(2, { timeout: 20_000 });

const expanded = () => composer.getAttribute('aria-expanded');
const files = page.getByRole('listbox', { name: '工作区文件' });

await composer.fill('看一下 @agent');
await expect(files).toBeVisible();

// A space ends the query — narrowing an `@` search by a second word, which
// the retired popup allowed, is gone.
await composer.pressSequentially(' write');
await expect.poll(expanded).toBe('false');

// A non-boundary `@` is not a trigger.
await composer.fill('mail user@host.com');
await expect.poll(expanded).toBe('false');

// The nearest boundary wins.
await composer.fill('@a /b');
await expect(page.getByRole('listbox', { name: /技能/ })).toBeVisible();
await expect(files).toHaveCount(0);

await composer.fill('看一下 @agent');

const listbox = page.getByRole('listbox', { name: '工作区文件' });
Expand All@@ -33,7 +101,8 @@ test('a picked file mention becomes an inline token and sends as its path', asyn
expect(tokenWidth).toBeLessThan(lineWidth / 2);

await composer.press('Enter');
const bubble = page.getByLabel('你发送的消息').first();
// Third send of this journey: anchor on the newest bubble.
const bubble = page.getByLabel('你发送的消息').last();
await expect(bubble).toBeVisible();
const sentFileBadges = bubble.locator('.maka-chat-message-bubble-user .astryx-badge');
await expect(sentFileBadges).toHaveCount(1);
Expand All@@ -50,7 +119,7 @@ test('a picked file mention becomes an inline token and sends as its path', asyn
).toBeVisible();

await page.reload();
const reloadedBubble = page.getByLabel('你发送的消息').first();
const reloadedBubble = page.getByLabel('你发送的消息').last();
await expect(reloadedBubble).toBeVisible();
await expect(
reloadedBubble.locator('.maka-chat-message-bubble-user .astryx-badge'),
Expand All@@ -59,78 +128,3 @@ test('a picked file mention becomes an inline token and sends as its path', asyn
'普通文本 @.maka/skills/agent-write/SKILL.md',
);
});

/**
* Upstream contract: the trigger boundaries are Astryx's `findActiveTrigger`
* now, and it is NOT equivalent to the `detectMentionTrigger` it replaced — a
* space ends an `@` query, so a path with a space in it can no longer be
* searched. Pin the grammar we actually depend on so an Astryx upgrade that
* moves a boundary fails here rather than in front of a user.
*/
test('the trigger menu opens exactly on the boundaries we depend on', async ({
invocableSkillsWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
const expanded = () => composer.getAttribute('aria-expanded');
const files = page.getByRole('listbox', { name: '工作区文件' });

await composer.fill('看一下 @agent');
await expect(files).toBeVisible();

// A space ends the query — narrowing an `@` search by a second word, which
// the retired popup allowed, is gone.
await composer.pressSequentially(' write');
await expect.poll(expanded).toBe('false');

// A non-boundary `@` is not a trigger.
await composer.fill('mail user@host.com');
await expect.poll(expanded).toBe('false');

// The nearest boundary wins.
await composer.fill('@a /b');
await expect(page.getByRole('listbox', { name: /技能/ })).toBeVisible();
await expect(files).toHaveCount(0);
});

/**
* An open menu with nothing highlighted (still loading, or no matches) leaves
* Enter unconsumed. It must not send the draft out from under the popup — and
* it must not deadlock either: "no matches" is a stable state, so swallowing
* Enter forever would leave the keyboard unable to send at all.
*/
test('Enter with an open, empty trigger menu withholds one send, not every send', async ({
invocableSkillsWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('@zzzznomatchzzzz');
await expect(page.getByRole('listbox', { name: '工作区文件' })).toBeVisible();

// A leaked send is asynchronous, so `toHaveCount(0)` here would pass before
// it lands — and a second Enter sending the same text would then hide it.
// Withhold, retype into something distinguishable, and pin the total.
await composer.press('Enter');
await composer.press('Enter');
await expect(page.getByText('Fake backend received: @zzzznomatchzzzz')).toBeVisible();
await expect(page.getByLabel('你发送的消息')).toHaveCount(1);
});

/**
* The menu has to follow the caret, not just the text. Astryx recomputes the
* active trigger only on `input`, so an arrow key off the query used to leave
* the menu open over a trigger no longer under the cursor — and the next Enter
* spliced a token in at the stale offset instead of sending.
*/
test('moving the caret off the query closes the trigger menu', async ({
invocableSkillsWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('看一下 @agent');
await expect(page.getByRole('listbox', { name: '工作区文件' })).toBeVisible();

for (let index = 0; index < 6; index += 1) await composer.press('ArrowLeft');
await expect.poll(() => composer.getAttribute('aria-expanded')).toBe('false');

await composer.press('Enter');
await expect(page.getByText('Fake backend received: 看一下 @agent')).toBeVisible();
await expect(composer.locator('[data-astryx-token]')).toHaveCount(0);
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' test(desktop): merge same-fixture e2e assertions into coherent journeys by UncertaintyDeterminesYou4ndMe · Pull Request #2486 · apache/maka · GitHub
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 7 additions & 11 deletions apps/desktop/e2e/ask-user-question.spec.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
import { FAKE_ASK_USER_QUESTION_PROMPT } from '@maka/runtime';
import { test, expect, COMPOSER_INPUT } from './fixtures.js';

test('rehydrates a prompt the surface never received live', async ({ window: page }) => {
// One seeded prompt, one launch: the reload-rehydration contract and the
// answer flow are consecutive phases of the same parked turn. Answering
// *after* the reload is the stronger form of both tests — it proves the
// rehydrated prompt is not a rendering of lost state but the live turn.
test('rehydrates a prompt across reload, then answers all three questions in the same turn', async ({
window: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill(FAKE_ASK_USER_QUESTION_PROMPT);
await composer.press('Enter');
Expand All@@ -20,16 +26,6 @@ test('rehydrates a prompt the surface never received live', async ({ window: pag
.first()
.click();

await expect(prompt).toBeVisible();
await expect(prompt.getByText('1 / 3', { exact: true })).toBeVisible();
});

test('answers three questions and continues the same fake-backend turn', async ({ window: page }) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill(FAKE_ASK_USER_QUESTION_PROMPT);
await composer.press('Enter');

const prompt = page.locator('.maka-user-question-prompt');
await expect(prompt).toBeVisible();
await expect(page.locator('.maka-composer')).toBeHidden();
await expect(prompt.getByText('1 / 3', { exact: true })).toBeVisible();
Expand Down
58 changes: 27 additions & 31 deletions apps/desktop/e2e/attachment.spec.ts
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,12 @@
import { test, expect, COMPOSER_INPUT } from './fixtures';

test('chat input preserves an IME composition when a file paste arrives', async ({ window: page }) => {
const firstSend = page.locator(COMPOSER_INPUT);
await firstSend.fill('ime-paste-test');
await firstSend.press('Enter');
await expect(page.getByText(/Fake backend received: ime-paste-test/)).toBeVisible();

const composer = page.locator('.maka-composer[data-maka-file-drop-target="true"]');
const editable = composer.locator('[contenteditable="true"]');

const pasteResults = await editable.evaluate((input) => {
const dispatchFilePaste = () => {
const clipboardData = new DataTransfer();
clipboardData.items.add(new File(['content'], 'note.txt', { type: 'text/plain' }));
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', { value: clipboardData });
input.dispatchEvent(event);
return event.defaultPrevented;
};

input.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }));
const duringComposition = dispatchFilePaste();
input.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }));
const afterComposition = dispatchFilePaste();

return { duringComposition, afterComposition };
});

expect(pasteResults).toEqual({ duringComposition: false, afterComposition: true });
});

/**
* Attachment upload + ingest: enter the chat view, drop a file onto the main
* composer, confirm it shows as a pending token, then send the message and
* verify the fake backend received the attachments by name. Uses Playwright's
* DataTransfer + dispatchEvent because the composer has no <input type=file>.
*/
test('a mixed attachment send has the Astryx message hierarchy', async ({ window: page }) => {
test('a mixed attachment send has the Astryx message hierarchy, and IME composition survives a file paste', async ({ window: page }) => {
// Enter chat view by sending a first message from the composer, which
// creates the session on send.
const firstSend = page.locator(COMPOSER_INPUT);
Expand DownExpand Up@@ -152,4 +122,30 @@ test('a mixed attachment send has the Astryx message hierarchy', async ({ window

await page.keyboard.press('Escape');
await expect(page.locator('.astryx-lightbox')).not.toBeVisible();

// IME composition vs file paste, in the same window: pastes must not be
// intercepted mid-composition, and must be intercepted after it. Runs after
// the attachment journey because the post-composition paste stages a chip.
const editable = page
.locator('.maka-composer[data-maka-file-drop-target="true"]')
.locator('[contenteditable="true"]');
const pasteResults = await editable.evaluate((input) => {
const dispatchFilePaste = () => {
const clipboardData = new DataTransfer();
clipboardData.items.add(new File(['content'], 'note.txt', { type: 'text/plain' }));
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', { value: clipboardData });
input.dispatchEvent(event);
return event.defaultPrevented;
};

input.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }));
const duringComposition = dispatchFilePaste();
input.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }));
const afterComposition = dispatchFilePaste();

return { duringComposition, afterComposition };
});

expect(pasteResults).toEqual({ duringComposition: false, afterComposition: true });
});
9 changes: 4 additions & 5 deletions apps/desktop/e2e/bot-onboarding.spec.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
import { test, expect } from './fixtures';

test('IM 快捷接入完成真实 QR session、扫码状态和本机凭据落盘', async ({ botSettingsWindow: page }) => {
test('IM 快捷接入完成真实 QR session、凭据落盘,取消与过期二维码可恢复', async ({ botSettingsWindow: page }) => {
const settings = page.getByRole('main', { name: '设置内容' });
await expect(settings.getByRole('heading', { name: '远程接入' })).toBeVisible();

Expand DownExpand Up@@ -32,11 +32,10 @@ test('IM 快捷接入完成真实 QR session、扫码状态和本机凭据落盘

await dialog.getByRole('button', { name: '完成' }).click();
await expect(dialog).toBeHidden();
});

test('关闭扫码弹窗会取消迟到结果,过期二维码可以重新生成', async ({ botSettingsWindow: page }) => {
const settings = page.getByRole('main', { name: '设置内容' });

// Same window, next channels: cancellation races, expiry regeneration, and
// the Lark variant are independent flows over the same seeded settings.
await settings.getByRole('button', { name: '返回远程接入' }).click();
await settings.getByRole('button', { name: '接入 微信' }).click();
await settings.getByRole('button', { name: '扫码登录' }).click();
const wechatDialog = page.getByRole('dialog', { name: '微信扫码登录' });
Expand Down
150 changes: 72 additions & 78 deletions apps/desktop/e2e/composer-mention-token.spec.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,28 @@
import { expect, test, COMPOSER_INPUT } from './fixtures';

// One seeded window, four phases over the same composer. Order is dictated
// by the message-count pins: the empty-menu double-Enter phase asserts a
// total of exactly one sent message, so it runs first; the token phase sends
// last and anchors on the newest bubble.
/**
* An open menu with nothing highlighted (still loading, or no matches) leaves
* Enter unconsumed. It must not send the draft out from under the popup — and
* it must not deadlock either: "no matches" is a stable state, so swallowing
* Enter forever would leave the keyboard unable to send at all.
*/
/**
* The menu has to follow the caret, not just the text. Astryx recomputes the
* active trigger only on `input`, so an arrow key off the query used to leave
* the menu open over a trigger no longer under the cursor — and the next Enter
* spliced a token in at the stale offset instead of sending.
*/
/**
* Upstream contract: the trigger boundaries are Astryx's `findActiveTrigger`
* now, and it is NOT equivalent to the `detectMentionTrigger` it replaced — a
* space ends an `@` query, so a path with a space in it can no longer be
* searched. Pin the grammar we actually depend on so an Astryx upgrade that
* moves a boundary fails here rather than in front of a user.
*/
/**
* The `@` file trigger: menu → inline token → the path the backend receives.
* The token is a real chip in the draft now, so this also pins the one cascade
Expand All@@ -8,10 +31,55 @@ import { expect, test, COMPOSER_INPUT } from './fixtures';
* `contenteditable="false"` stretched it to the full line and pushed the
* surrounding text onto separate rows.
*/
test('a picked file mention becomes an inline token and sends as its path', async ({
test('the @ trigger: empty-menu sends, caret boundaries, trigger grammar, and the inline token round trip', async ({
invocableSkillsWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('@zzzznomatchzzzz');
await expect(page.getByRole('listbox', { name: '工作区文件' })).toBeVisible();

// A leaked send is asynchronous, so `toHaveCount(0)` here would pass before
// it lands — and a second Enter sending the same text would then hide it.
// Withhold, retype into something distinguishable, and pin the total.
await composer.press('Enter');
await composer.press('Enter');
await expect(page.getByText('Fake backend received: @zzzznomatchzzzz')).toBeVisible();
await expect(page.getByLabel('你发送的消息')).toHaveCount(1);
// Settle before the next phase sends: an Enter during a streaming turn
// becomes steering instead of a new message.
await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { timeout: 20_000 });

await composer.fill('看一下 @agent');
await expect(page.getByRole('listbox', { name: '工作区文件' })).toBeVisible();

for (let index = 0; index < 6; index += 1) await composer.press('ArrowLeft');
await expect.poll(() => composer.getAttribute('aria-expanded')).toBe('false');

await composer.press('Enter');
await expect(page.getByText('Fake backend received: 看一下 @agent')).toBeVisible();
await expect(composer.locator('[data-astryx-token]')).toHaveCount(0);
await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(2, { timeout: 20_000 });

const expanded = () => composer.getAttribute('aria-expanded');
const files = page.getByRole('listbox', { name: '工作区文件' });

await composer.fill('看一下 @agent');
await expect(files).toBeVisible();

// A space ends the query — narrowing an `@` search by a second word, which
// the retired popup allowed, is gone.
await composer.pressSequentially(' write');
await expect.poll(expanded).toBe('false');

// A non-boundary `@` is not a trigger.
await composer.fill('mail user@host.com');
await expect.poll(expanded).toBe('false');

// The nearest boundary wins.
await composer.fill('@a /b');
await expect(page.getByRole('listbox', { name: /技能/ })).toBeVisible();
await expect(files).toHaveCount(0);

await composer.fill('看一下 @agent');

const listbox = page.getByRole('listbox', { name: '工作区文件' });
Expand All@@ -33,7 +101,8 @@ test('a picked file mention becomes an inline token and sends as its path', asyn
expect(tokenWidth).toBeLessThan(lineWidth / 2);

await composer.press('Enter');
const bubble = page.getByLabel('你发送的消息').first();
// Third send of this journey: anchor on the newest bubble.
const bubble = page.getByLabel('你发送的消息').last();
await expect(bubble).toBeVisible();
const sentFileBadges = bubble.locator('.maka-chat-message-bubble-user .astryx-badge');
await expect(sentFileBadges).toHaveCount(1);
Expand All@@ -50,7 +119,7 @@ test('a picked file mention becomes an inline token and sends as its path', asyn
).toBeVisible();

await page.reload();
const reloadedBubble = page.getByLabel('你发送的消息').first();
const reloadedBubble = page.getByLabel('你发送的消息').last();
await expect(reloadedBubble).toBeVisible();
await expect(
reloadedBubble.locator('.maka-chat-message-bubble-user .astryx-badge'),
Expand All@@ -59,78 +128,3 @@ test('a picked file mention becomes an inline token and sends as its path', asyn
'普通文本 @.maka/skills/agent-write/SKILL.md',
);
});

/**
* Upstream contract: the trigger boundaries are Astryx's `findActiveTrigger`
* now, and it is NOT equivalent to the `detectMentionTrigger` it replaced — a
* space ends an `@` query, so a path with a space in it can no longer be
* searched. Pin the grammar we actually depend on so an Astryx upgrade that
* moves a boundary fails here rather than in front of a user.
*/
test('the trigger menu opens exactly on the boundaries we depend on', async ({
invocableSkillsWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
const expanded = () => composer.getAttribute('aria-expanded');
const files = page.getByRole('listbox', { name: '工作区文件' });

await composer.fill('看一下 @agent');
await expect(files).toBeVisible();

// A space ends the query — narrowing an `@` search by a second word, which
// the retired popup allowed, is gone.
await composer.pressSequentially(' write');
await expect.poll(expanded).toBe('false');

// A non-boundary `@` is not a trigger.
await composer.fill('mail user@host.com');
await expect.poll(expanded).toBe('false');

// The nearest boundary wins.
await composer.fill('@a /b');
await expect(page.getByRole('listbox', { name: /技能/ })).toBeVisible();
await expect(files).toHaveCount(0);
});

/**
* An open menu with nothing highlighted (still loading, or no matches) leaves
* Enter unconsumed. It must not send the draft out from under the popup — and
* it must not deadlock either: "no matches" is a stable state, so swallowing
* Enter forever would leave the keyboard unable to send at all.
*/
test('Enter with an open, empty trigger menu withholds one send, not every send', async ({
invocableSkillsWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('@zzzznomatchzzzz');
await expect(page.getByRole('listbox', { name: '工作区文件' })).toBeVisible();

// A leaked send is asynchronous, so `toHaveCount(0)` here would pass before
// it lands — and a second Enter sending the same text would then hide it.
// Withhold, retype into something distinguishable, and pin the total.
await composer.press('Enter');
await composer.press('Enter');
await expect(page.getByText('Fake backend received: @zzzznomatchzzzz')).toBeVisible();
await expect(page.getByLabel('你发送的消息')).toHaveCount(1);
});

/**
* The menu has to follow the caret, not just the text. Astryx recomputes the
* active trigger only on `input`, so an arrow key off the query used to leave
* the menu open over a trigger no longer under the cursor — and the next Enter
* spliced a token in at the stale offset instead of sending.
*/
test('moving the caret off the query closes the trigger menu', async ({
invocableSkillsWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('看一下 @agent');
await expect(page.getByRole('listbox', { name: '工作区文件' })).toBeVisible();

for (let index = 0; index < 6; index += 1) await composer.press('ArrowLeft');
await expect.poll(() => composer.getAttribute('aria-expanded')).toBe('false');

await composer.press('Enter');
await expect(page.getByText('Fake backend received: 看一下 @agent')).toBeVisible();
await expect(composer.locator('[data-astryx-token]')).toHaveCount(0);
});
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); test(desktop): merge same-fixture e2e assertions into coherent journeys by UncertaintyDeterminesYou4ndMe · Pull Request #2486 · apache/maka · GitHub
Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 7 additions & 11 deletions apps/desktop/e2e/ask-user-question.spec.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
import { FAKE_ASK_USER_QUESTION_PROMPT } from '@maka/runtime';
import { test, expect, COMPOSER_INPUT } from './fixtures.js';

test('rehydrates a prompt the surface never received live', async ({ window: page }) => {
// One seeded prompt, one launch: the reload-rehydration contract and the
// answer flow are consecutive phases of the same parked turn. Answering
// *after* the reload is the stronger form of both tests — it proves the
// rehydrated prompt is not a rendering of lost state but the live turn.
test('rehydrates a prompt across reload, then answers all three questions in the same turn', async ({
window: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill(FAKE_ASK_USER_QUESTION_PROMPT);
await composer.press('Enter');
Expand All@@ -20,16 +26,6 @@ test('rehydrates a prompt the surface never received live', async ({ window: pag
.first()
.click();

await expect(prompt).toBeVisible();
await expect(prompt.getByText('1 / 3', { exact: true })).toBeVisible();
});

test('answers three questions and continues the same fake-backend turn', async ({ window: page }) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill(FAKE_ASK_USER_QUESTION_PROMPT);
await composer.press('Enter');

const prompt = page.locator('.maka-user-question-prompt');
await expect(prompt).toBeVisible();
await expect(page.locator('.maka-composer')).toBeHidden();
await expect(prompt.getByText('1 / 3', { exact: true })).toBeVisible();
Expand Down
58 changes: 27 additions & 31 deletions apps/desktop/e2e/attachment.spec.ts
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,12 @@
import { test, expect, COMPOSER_INPUT } from './fixtures';

test('chat input preserves an IME composition when a file paste arrives', async ({ window: page }) => {
const firstSend = page.locator(COMPOSER_INPUT);
await firstSend.fill('ime-paste-test');
await firstSend.press('Enter');
await expect(page.getByText(/Fake backend received: ime-paste-test/)).toBeVisible();

const composer = page.locator('.maka-composer[data-maka-file-drop-target="true"]');
const editable = composer.locator('[contenteditable="true"]');

const pasteResults = await editable.evaluate((input) => {
const dispatchFilePaste = () => {
const clipboardData = new DataTransfer();
clipboardData.items.add(new File(['content'], 'note.txt', { type: 'text/plain' }));
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', { value: clipboardData });
input.dispatchEvent(event);
return event.defaultPrevented;
};

input.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }));
const duringComposition = dispatchFilePaste();
input.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }));
const afterComposition = dispatchFilePaste();

return { duringComposition, afterComposition };
});

expect(pasteResults).toEqual({ duringComposition: false, afterComposition: true });
});

/**
* Attachment upload + ingest: enter the chat view, drop a file onto the main
* composer, confirm it shows as a pending token, then send the message and
* verify the fake backend received the attachments by name. Uses Playwright's
* DataTransfer + dispatchEvent because the composer has no <input type=file>.
*/
test('a mixed attachment send has the Astryx message hierarchy', async ({ window: page }) => {
test('a mixed attachment send has the Astryx message hierarchy, and IME composition survives a file paste', async ({ window: page }) => {
// Enter chat view by sending a first message from the composer, which
// creates the session on send.
const firstSend = page.locator(COMPOSER_INPUT);
Expand DownExpand Up@@ -152,4 +122,30 @@ test('a mixed attachment send has the Astryx message hierarchy', async ({ window

await page.keyboard.press('Escape');
await expect(page.locator('.astryx-lightbox')).not.toBeVisible();

// IME composition vs file paste, in the same window: pastes must not be
// intercepted mid-composition, and must be intercepted after it. Runs after
// the attachment journey because the post-composition paste stages a chip.
const editable = page
.locator('.maka-composer[data-maka-file-drop-target="true"]')
.locator('[contenteditable="true"]');
const pasteResults = await editable.evaluate((input) => {
const dispatchFilePaste = () => {
const clipboardData = new DataTransfer();
clipboardData.items.add(new File(['content'], 'note.txt', { type: 'text/plain' }));
const event = new Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', { value: clipboardData });
input.dispatchEvent(event);
return event.defaultPrevented;
};

input.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }));
const duringComposition = dispatchFilePaste();
input.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true }));
const afterComposition = dispatchFilePaste();

return { duringComposition, afterComposition };
});

expect(pasteResults).toEqual({ duringComposition: false, afterComposition: true });
});
9 changes: 4 additions & 5 deletions apps/desktop/e2e/bot-onboarding.spec.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
import { test, expect } from './fixtures';

test('IM 快捷接入完成真实 QR session、扫码状态和本机凭据落盘', async ({ botSettingsWindow: page }) => {
test('IM 快捷接入完成真实 QR session、凭据落盘,取消与过期二维码可恢复', async ({ botSettingsWindow: page }) => {
const settings = page.getByRole('main', { name: '设置内容' });
await expect(settings.getByRole('heading', { name: '远程接入' })).toBeVisible();

Expand DownExpand Up@@ -32,11 +32,10 @@ test('IM 快捷接入完成真实 QR session、扫码状态和本机凭据落盘

await dialog.getByRole('button', { name: '完成' }).click();
await expect(dialog).toBeHidden();
});

test('关闭扫码弹窗会取消迟到结果,过期二维码可以重新生成', async ({ botSettingsWindow: page }) => {
const settings = page.getByRole('main', { name: '设置内容' });

// Same window, next channels: cancellation races, expiry regeneration, and
// the Lark variant are independent flows over the same seeded settings.
await settings.getByRole('button', { name: '返回远程接入' }).click();
await settings.getByRole('button', { name: '接入 微信' }).click();
await settings.getByRole('button', { name: '扫码登录' }).click();
const wechatDialog = page.getByRole('dialog', { name: '微信扫码登录' });
Expand Down
150 changes: 72 additions & 78 deletions apps/desktop/e2e/composer-mention-token.spec.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,28 @@
import { expect, test, COMPOSER_INPUT } from './fixtures';

// One seeded window, four phases over the same composer. Order is dictated
// by the message-count pins: the empty-menu double-Enter phase asserts a
// total of exactly one sent message, so it runs first; the token phase sends
// last and anchors on the newest bubble.
/**
* An open menu with nothing highlighted (still loading, or no matches) leaves
* Enter unconsumed. It must not send the draft out from under the popup — and
* it must not deadlock either: "no matches" is a stable state, so swallowing
* Enter forever would leave the keyboard unable to send at all.
*/
/**
* The menu has to follow the caret, not just the text. Astryx recomputes the
* active trigger only on `input`, so an arrow key off the query used to leave
* the menu open over a trigger no longer under the cursor — and the next Enter
* spliced a token in at the stale offset instead of sending.
*/
/**
* Upstream contract: the trigger boundaries are Astryx's `findActiveTrigger`
* now, and it is NOT equivalent to the `detectMentionTrigger` it replaced — a
* space ends an `@` query, so a path with a space in it can no longer be
* searched. Pin the grammar we actually depend on so an Astryx upgrade that
* moves a boundary fails here rather than in front of a user.
*/
/**
* The `@` file trigger: menu → inline token → the path the backend receives.
* The token is a real chip in the draft now, so this also pins the one cascade
Expand All@@ -8,10 +31,55 @@ import { expect, test, COMPOSER_INPUT } from './fixtures';
* `contenteditable="false"` stretched it to the full line and pushed the
* surrounding text onto separate rows.
*/
test('a picked file mention becomes an inline token and sends as its path', async ({
test('the @ trigger: empty-menu sends, caret boundaries, trigger grammar, and the inline token round trip', async ({
invocableSkillsWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('@zzzznomatchzzzz');
await expect(page.getByRole('listbox', { name: '工作区文件' })).toBeVisible();

// A leaked send is asynchronous, so `toHaveCount(0)` here would pass before
// it lands — and a second Enter sending the same text would then hide it.
// Withhold, retype into something distinguishable, and pin the total.
await composer.press('Enter');
await composer.press('Enter');
await expect(page.getByText('Fake backend received: @zzzznomatchzzzz')).toBeVisible();
await expect(page.getByLabel('你发送的消息')).toHaveCount(1);
// Settle before the next phase sends: an Enter during a streaming turn
// becomes steering instead of a new message.
await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { timeout: 20_000 });

await composer.fill('看一下 @agent');
await expect(page.getByRole('listbox', { name: '工作区文件' })).toBeVisible();

for (let index = 0; index < 6; index += 1) await composer.press('ArrowLeft');
await expect.poll(() => composer.getAttribute('aria-expanded')).toBe('false');

await composer.press('Enter');
await expect(page.getByText('Fake backend received: 看一下 @agent')).toBeVisible();
await expect(composer.locator('[data-astryx-token]')).toHaveCount(0);
await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(2, { timeout: 20_000 });

const expanded = () => composer.getAttribute('aria-expanded');
const files = page.getByRole('listbox', { name: '工作区文件' });

await composer.fill('看一下 @agent');
await expect(files).toBeVisible();

// A space ends the query — narrowing an `@` search by a second word, which
// the retired popup allowed, is gone.
await composer.pressSequentially(' write');
await expect.poll(expanded).toBe('false');

// A non-boundary `@` is not a trigger.
await composer.fill('mail user@host.com');
await expect.poll(expanded).toBe('false');

// The nearest boundary wins.
await composer.fill('@a /b');
await expect(page.getByRole('listbox', { name: /技能/ })).toBeVisible();
await expect(files).toHaveCount(0);

await composer.fill('看一下 @agent');

const listbox = page.getByRole('listbox', { name: '工作区文件' });
Expand All@@ -33,7 +101,8 @@ test('a picked file mention becomes an inline token and sends as its path', asyn
expect(tokenWidth).toBeLessThan(lineWidth / 2);

await composer.press('Enter');
const bubble = page.getByLabel('你发送的消息').first();
// Third send of this journey: anchor on the newest bubble.
const bubble = page.getByLabel('你发送的消息').last();
await expect(bubble).toBeVisible();
const sentFileBadges = bubble.locator('.maka-chat-message-bubble-user .astryx-badge');
await expect(sentFileBadges).toHaveCount(1);
Expand All@@ -50,7 +119,7 @@ test('a picked file mention becomes an inline token and sends as its path', asyn
).toBeVisible();

await page.reload();
const reloadedBubble = page.getByLabel('你发送的消息').first();
const reloadedBubble = page.getByLabel('你发送的消息').last();
await expect(reloadedBubble).toBeVisible();
await expect(
reloadedBubble.locator('.maka-chat-message-bubble-user .astryx-badge'),
Expand All@@ -59,78 +128,3 @@ test('a picked file mention becomes an inline token and sends as its path', asyn
'普通文本 @.maka/skills/agent-write/SKILL.md',
);
});

/**
* Upstream contract: the trigger boundaries are Astryx's `findActiveTrigger`
* now, and it is NOT equivalent to the `detectMentionTrigger` it replaced — a
* space ends an `@` query, so a path with a space in it can no longer be
* searched. Pin the grammar we actually depend on so an Astryx upgrade that
* moves a boundary fails here rather than in front of a user.
*/
test('the trigger menu opens exactly on the boundaries we depend on', async ({
invocableSkillsWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
const expanded = () => composer.getAttribute('aria-expanded');
const files = page.getByRole('listbox', { name: '工作区文件' });

await composer.fill('看一下 @agent');
await expect(files).toBeVisible();

// A space ends the query — narrowing an `@` search by a second word, which
// the retired popup allowed, is gone.
await composer.pressSequentially(' write');
await expect.poll(expanded).toBe('false');

// A non-boundary `@` is not a trigger.
await composer.fill('mail user@host.com');
await expect.poll(expanded).toBe('false');

// The nearest boundary wins.
await composer.fill('@a /b');
await expect(page.getByRole('listbox', { name: /技能/ })).toBeVisible();
await expect(files).toHaveCount(0);
});

/**
* An open menu with nothing highlighted (still loading, or no matches) leaves
* Enter unconsumed. It must not send the draft out from under the popup — and
* it must not deadlock either: "no matches" is a stable state, so swallowing
* Enter forever would leave the keyboard unable to send at all.
*/
test('Enter with an open, empty trigger menu withholds one send, not every send', async ({
invocableSkillsWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('@zzzznomatchzzzz');
await expect(page.getByRole('listbox', { name: '工作区文件' })).toBeVisible();

// A leaked send is asynchronous, so `toHaveCount(0)` here would pass before
// it lands — and a second Enter sending the same text would then hide it.
// Withhold, retype into something distinguishable, and pin the total.
await composer.press('Enter');
await composer.press('Enter');
await expect(page.getByText('Fake backend received: @zzzznomatchzzzz')).toBeVisible();
await expect(page.getByLabel('你发送的消息')).toHaveCount(1);
});

/**
* The menu has to follow the caret, not just the text. Astryx recomputes the
* active trigger only on `input`, so an arrow key off the query used to leave
* the menu open over a trigger no longer under the cursor — and the next Enter
* spliced a token in at the stale offset instead of sending.
*/
test('moving the caret off the query closes the trigger menu', async ({
invocableSkillsWindow: page,
}) => {
const composer = page.locator(COMPOSER_INPUT);
await composer.fill('看一下 @agent');
await expect(page.getByRole('listbox', { name: '工作区文件' })).toBeVisible();

for (let index = 0; index < 6; index += 1) await composer.press('ArrowLeft');
await expect.poll(() => composer.getAttribute('aria-expanded')).toBe('false');

await composer.press('Enter');
await expect(page.getByText('Fake backend received: 看一下 @agent')).toBeVisible();
await expect(composer.locator('[data-astryx-token]')).toHaveCount(0);
});
Loading