feat: release 2.2.0 with PC-only tests and execution reliability fixes
Unify task scheduling and queue state, fix Android input and observation recovery, preserve screenshot paths, and match final review criteria accurately. Add collapsible copyable results and publish the complete 2.1.0 upgrade notes. Validation: 3189 backend tests, 326 frontend tests, 24 client tests, production build and quality gates.
This commit is contained in:
1 parent
fac4ccb760
commit
be6e8c2abd
79 files changed
+2263
-277
No files matched your search
@@ -0,0 +1,58 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { AgentStreamComponent } from './agent-stream.component';
|
||||
|
||||
describe('action card text selection', () => {
|
||||
let component: AgentStreamComponent;
|
||||
let header: HTMLDivElement;
|
||||
let outside: HTMLDivElement;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideHttpClient(), provideHttpClientTesting(), provideRouter([])]
|
||||
});
|
||||
component = TestBed.createComponent(AgentStreamComponent).componentInstance;
|
||||
header = document.createElement('div');
|
||||
header.textContent = '点击元素 Projects';
|
||||
header.addEventListener('click', event => component.toggleActionExpanded('action-1', event));
|
||||
outside = document.createElement('div');
|
||||
outside.textContent = '其他步骤';
|
||||
document.body.append(header, outside);
|
||||
window.getSelection()?.removeAllRanges();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.getSelection()?.removeAllRanges();
|
||||
header.remove();
|
||||
outside.remove();
|
||||
});
|
||||
|
||||
function selectText(element: HTMLElement): void {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(element);
|
||||
window.getSelection()?.addRange(range);
|
||||
}
|
||||
|
||||
it('keeps selected header text and the expanded card in place for copying', () => {
|
||||
component.toggleActionExpanded('action-1');
|
||||
selectText(header);
|
||||
header.click();
|
||||
expect(component.isActionExpanded('action-1')).toBeTrue();
|
||||
expect(window.getSelection()?.toString()).toBe('点击元素 Projects');
|
||||
});
|
||||
|
||||
it('expands and collapses normally without selected text', () => {
|
||||
header.click();
|
||||
expect(component.isActionExpanded('action-1')).toBeTrue();
|
||||
header.click();
|
||||
expect(component.isActionExpanded('action-1')).toBeFalse();
|
||||
});
|
||||
|
||||
it('allows another card to open while unrelated text is selected', () => {
|
||||
selectText(outside);
|
||||
header.click();
|
||||
expect(component.isActionExpanded('action-1')).toBeTrue();
|
||||
});
|
||||
});
|
||||
@@ -747,9 +747,21 @@ limitations under the License.
|
||||
</div>
|
||||
}
|
||||
@for (param of getActionExtraParams(item.data); track trackParam($index, param)) {
|
||||
<div class="grid-item">
|
||||
<div class="grid-item" [class.full-width]="param.key === 'Result'">
|
||||
<span class="grid-label">{{ param.key }}</span>
|
||||
<span class="grid-value code-pill">{{ param.value }}</span>
|
||||
@if (param.key === 'Result') {
|
||||
<div class="action-result-preview">{{ param.value | slice:0:180 }}{{ param.value.length > 180 ? '…' : '' }}</div>
|
||||
<details class="action-result-detail">
|
||||
<summary>
|
||||
<span class="result-expand-label">展开完整结果</span>
|
||||
<span class="result-collapse-label">收起结果</span>
|
||||
<span class="result-length">{{ param.value.length }} 字符</span>
|
||||
</summary>
|
||||
<pre tabindex="0" aria-label="完整操作结果,可选择复制">{{ param.value }}</pre>
|
||||
</details>
|
||||
} @else {
|
||||
<span class="grid-value code-pill">{{ param.value }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<!-- Fallback item if all detail fields are empty -->
|
||||
|
||||
@@ -1851,8 +1851,8 @@ $stream-row-height: 25px;
|
||||
gap: 8px;
|
||||
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
cursor: auto;
|
||||
user-select: text;
|
||||
|
||||
&.plugin-tool-card.is-expanded {
|
||||
width: min(100%, 760px);
|
||||
@@ -2018,6 +2018,7 @@ $stream-row-height: 25px;
|
||||
|
||||
.action-card-header {
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid var(--app-border, #f1f5f9);
|
||||
@@ -4052,6 +4053,40 @@ $stream-row-height: 25px;
|
||||
&:hover { background: var(--app-surface-soft, #f1f5f9); }
|
||||
&:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; }
|
||||
}
|
||||
.action-result-preview {
|
||||
max-height: 4.8em;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
font: 12px/1.6 Consolas, monospace;
|
||||
color: var(--app-text-secondary, #475569);
|
||||
}
|
||||
.action-result-detail {
|
||||
min-width: 0;
|
||||
margin-top: 6px;
|
||||
border: 1px solid var(--app-border, #e2e8f0);
|
||||
border-radius: 6px;
|
||||
color: var(--app-text-control, #334155);
|
||||
background: var(--app-surface-soft, #f8fafc);
|
||||
summary { padding: 8px 10px; cursor: pointer; font-size: 12px; }
|
||||
.result-length { margin-left: 8px; color: var(--app-text-muted, #64748b); font-size: 11px; }
|
||||
.result-collapse-label { display: none; }
|
||||
&[open] .result-expand-label { display: none; }
|
||||
&[open] .result-collapse-label { display: inline; }
|
||||
summary:focus-visible, pre:focus-visible { outline: 2px solid var(--app-primary-text, #1a73e8); outline-offset: 2px; }
|
||||
pre {
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-top: 1px solid var(--app-border, #e2e8f0);
|
||||
font: 12px/1.6 Consolas, monospace;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
user-select: text;
|
||||
cursor: text;
|
||||
}
|
||||
}
|
||||
:host ::ng-deep .report-markdown-body {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
@@ -1454,6 +1454,11 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
public toggleActionExpanded(cardId: string, event?: Event, defaultExpanded: boolean = false): void {
|
||||
if (event) {
|
||||
event.stopPropagation();
|
||||
const selection = window.getSelection();
|
||||
if (selection && !selection.isCollapsed && event.currentTarget instanceof Node
|
||||
&& selection.containsNode(event.currentTarget, true)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const isCurrentlyExpanded = this.isActionExpanded(cardId, defaultExpanded);
|
||||
if (isCurrentlyExpanded) {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</button>
|
||||
@if (open()) {
|
||||
<section class="plugin-options" aria-label="扩展插件设置">
|
||||
<p>照常描述安卓测试任务。需要操作电脑、浏览器等外部步骤时,AI 会按需调用已启用并授权的插件。</p>
|
||||
<p>{{ target() === 'computer' ? '仅在电脑端执行。请启用并授权提供电脑画面观察的插件,例如 CUA。' : '照常描述安卓测试任务。需要操作电脑、浏览器等外部步骤时,AI 会按需调用已启用并授权的插件。' }}</p>
|
||||
<label class="auto-option"><input type="checkbox" [(ngModel)]="automatic" (ngModelChange)="emit()">自动使用可用插件</label>
|
||||
@if (loading()) { <p role="status">正在读取插件…</p> }
|
||||
@else if (error()) { <p class="error" role="alert">{{ error() }} <button type="button" class="text-button" (click)="load()">重试</button></p> }
|
||||
@@ -21,9 +21,9 @@
|
||||
}
|
||||
</div>
|
||||
} @empty { <p>还没有可用插件,请先安装、授权并启用。</p> }
|
||||
<p class="hint">不勾选任何插件时,本次任务只使用手机能力。</p>
|
||||
<p class="hint">{{ target() === 'computer' ? '仅 PC 任务需要选择至少一个提供电脑画面观察的插件。' : '不勾选任何插件时,本次任务只使用手机能力。' }}</p>
|
||||
} @else {
|
||||
<p class="hint">{{ available().length ? 'AI 会按需使用可用插件,无需逐次选择。关闭自动使用后,可指定本次允许使用的插件。' : '还没有可用插件,手机测试不受影响。' }}</p>
|
||||
<p class="hint">{{ available().length ? 'AI 会按需使用可用插件,无需逐次选择。关闭自动使用后,可指定本次允许使用的插件。' : target() === 'computer' ? '还没有可用电脑插件,请先安装、授权并启用。' : '还没有可用插件,手机测试不受影响。' }}</p>
|
||||
}
|
||||
<a routerLink="/plugins" class="manage-link">管理插件 <span class="material-symbols-outlined" aria-hidden="true">arrow_forward</span></a>
|
||||
</section>
|
||||
|
||||
@@ -75,4 +75,23 @@ describe('TaskPluginsComponent', () => {
|
||||
expect(fixture.nativeElement.textContent).toContain('重试');
|
||||
expect(fixture.componentInstance.automatic).toBeTrue();
|
||||
});
|
||||
|
||||
it('filters PC-compatible plugins and removes incompatible manual choices on target change', () => {
|
||||
const fixture = TestBed.createComponent(TaskPluginsComponent), component=fixture.componentInstance;
|
||||
component.toggle();
|
||||
const profile={name:'default',label:'默认',authorized:true,is_default:true};
|
||||
const plugin=(id:string,targets:string[])=>({enabled:true,manifest:{id,name:id,targets},profiles:[profile]});
|
||||
http.expectOne('/api/plugins').flush({plugins:[plugin('android-only',['android']),plugin('mixed-only',['mixed']),plugin('desktop',['computer'])]});
|
||||
component.automatic=false;component.selected={'android-only':'default'};
|
||||
const emitted=jasmine.createSpy('changed');component.changed.subscribe(emitted);
|
||||
fixture.componentRef.setInput('target','computer');fixture.detectChanges();
|
||||
expect(component.available().map(p=>p.manifest.id)).toEqual(['desktop']);
|
||||
expect(emitted).toHaveBeenCalledWith({plugins:[]});
|
||||
expect(fixture.nativeElement.textContent).toContain('画面观察');
|
||||
const choice:HTMLInputElement=fixture.nativeElement.querySelector('.choice input');choice.click();fixture.detectChanges();
|
||||
expect(emitted).toHaveBeenCalledWith({plugins:[{id:'desktop',profile:'default'}]});
|
||||
fixture.componentRef.setInput('target','android');fixture.detectChanges();
|
||||
expect(component.available().map(p=>p.manifest.id)).toEqual(['android-only','mixed-only']);
|
||||
expect(component.selected).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component, inject, output, signal } from '@angular/core';
|
||||
import { Component, inject, input, OnChanges, output, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { InstalledPlugin, PluginService } from '../../services/plugin.service';
|
||||
@@ -11,7 +11,8 @@ export interface TaskPluginOptions {
|
||||
selector: 'app-task-plugins', standalone: true, imports: [FormsModule, RouterLink],
|
||||
templateUrl: './task-plugins.component.html', styleUrl: './task-plugins.component.scss'
|
||||
})
|
||||
export class TaskPluginsComponent {
|
||||
export class TaskPluginsComponent implements OnChanges {
|
||||
target = input<'android' | 'computer'>('android');
|
||||
changed = output<TaskPluginOptions>();
|
||||
private api = inject(PluginService);
|
||||
automatic = true;
|
||||
@@ -26,7 +27,12 @@ export class TaskPluginsComponent {
|
||||
}
|
||||
available() {
|
||||
return this.plugins().filter(p => p.enabled && p.profiles.some(profile => profile.authorized) &&
|
||||
p.manifest.targets.some(target => target === 'android' || target === 'mixed'));
|
||||
p.manifest.targets.some(target => target === this.target() || (this.target() === 'android' && target === 'mixed')));
|
||||
}
|
||||
ngOnChanges() {
|
||||
const allowed = new Set(this.available().map(p => p.manifest.id));
|
||||
this.selected = Object.fromEntries(Object.entries(this.selected).filter(([id]) => allowed.has(id)));
|
||||
this.emit();
|
||||
}
|
||||
authorizedProfiles(plugin: InstalledPlugin) { return plugin.profiles.filter(p => p.authorized); }
|
||||
toggle() {
|
||||
|
||||
Reference in new issue
Block a user