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:
chenmingxuan committed 2026-10-27 01:13:41 +08:00
1 parent fac4ccb760
commit be6e8c2abd
79 files changed
+2263 -277

No files matched your search

@@ -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() {