Files
maestro/scripts/check-transcription.mjs
T
oss-sync 57702b4f34
CI / build-and-test (push) Canceled after 0s
sync: update from private repo (2336949a)
2026-09-17 11:29:31 +00:00

185 lines
15 KiB
JavaScript

// Local-only integration check: actual browser audio processing and AAO relay, synthetic ASR and translation servers.
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import { createServer } from 'node:http';
import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { once } from 'node:events';
import assert from 'node:assert/strict';
const root=resolve(dirname(fileURLToPath(import.meta.url)), '..');
const require=createRequire(root+'/package.json');
const express=require('express');
const Database=require('better-sqlite3');
const {WebSocketServer}=require('ws');
const {chromium}=require('playwright');
const {createTranscriptionApi}=await import(root+'/src/bridge/transcription-api.ts');
const {attachTranscriptionWs}=await import(root+'/src/bridge/transcription-ws.ts');
const upstream=new WebSocketServer({port:0}); await once(upstream,'listening');
let audioFrames=0, finals=0;
upstream.on('connection', ws=>{
let n=0;
ws.send('{"type":"ready"}');
ws.on('message',(data,binary)=>{
if(binary){audioFrames++;n++; if(n%3===0) ws.send(JSON.stringify({type:'transcript',text:`議題は AAO の文字起こし機能です。途中結果 ${n}`,is_final:false,segment_id:'session',segments:[]}));}
else {const m=JSON.parse(data.toString());if(m.type==='config')ws.send('{"type":"config_ack"}');if(m.type==='end'){finals++;ws.send(JSON.stringify({type:'transcript',text:'議題は AAO の文字起こし機能です。次回までに動作を確認します。',is_final:true,segment_id:'session',segments:[{id:'s1',start:0,end:2,speaker:'1',text:'議題は AAO の文字起こし機能です。',timestamp_source:'model'}]}));}}
});
});
const translatedRequests=[];
const translationApp=express();translationApp.use(express.json());
translationApp.post('/v1/chat/completions',(req,res)=>{
const content=req.body.messages?.[0]?.content;
assert.equal(req.body.model,'test-translategemma');
assert.equal(typeof content,'string');
assert(content.startsWith('<<<source>>>ja<<<target>>>en<<<text>>>'));
const original=content.split('<<<text>>>')[1]; translatedRequests.push(original);
setTimeout(()=>res.json({choices:[{finish_reason:'stop',message:{content:`Translated: ${original}`}}]}),100);
});
const translationServer=translationApp.listen(0,'127.0.0.1');await once(translationServer,'listening');
const translationEndpoint=`http://127.0.0.1:${translationServer.address().port}/v1`;
const tools={speechStreamingUrl:`ws://127.0.0.1:${upstream.address().port}/v1/audio/stream`,speechStreamingTimeout:10};
const configuredEndpoint=tools.speechStreamingUrl; tools.speechStreamingUrl='';
const db=new Database(':memory:');
const fixtureRoot=mkdtempSync(resolve(tmpdir(),'aao-chat-transcription-'));
const timestamp=new Date().toISOString();
const space={id:'browser-space',kind:'personal',title:'Browser Workspace',description:'',ownerId:'browser-test',visibility:'private',visibilityScopeOrgId:null,status:'open',brandColor:null,workspaceDir:fixtureRoot,createdAt:timestamp,updatedAt:timestamp,myRole:'owner'};
const tasks=[1,2].map(id=>({id,title:id===1?'会議 A':'会議 B',body:'Browser transcription check',pieceName:'chat',profile:'default',outputFormat:'text',askPolicy:'auto',priority:'normal',state:'succeeded',workspacePath:resolve(fixtureRoot,`task-${id}`),ownerId:'browser-test',ownerName:'Browser Test',visibility:'private',spaceId:space.id,createdAt:timestamp,updatedAt:timestamp,latestJob:null,subtasks:[],tags:[],allowedTools:[]}));
for(const task of tasks) mkdirSync(task.workspacePath,{recursive:true});
const provenance=[];
const repo={getLatestJobForIssue:async()=>null,getLocalTask:async id=>tasks.find(task=>task.id===id)??null,getSpace:()=>space,getSpaceMemberRole:()=> 'owner',userCanViewSpace:()=>true,recordProvenance:entry=>provenance.push(entry)};
const app=express();
app.use((req,res,next)=>{req.user={id:'browser-test',role:'admin',email:'[email protected]',name:'Browser Test'};next();});
app.use('/api/transcription',createTranscriptionApi({db,repo,authActive:true,getTools:()=>tools}));
app.use(express.json());
app.get('/api/auth/me',(req,res)=>res.json(req.user));
app.get('/api/setup/status',(_req,res)=>res.json({needsSetup:false,authActive:true}));
app.get('/api/config',(_req,res)=>res.json({config:{configVersion:2,tools,storage:{},llm:{workers:[]}},overriddenByEnv:{}}));
app.put('/api/config',(req,res)=>{Object.assign(tools,req.body.tools);res.json({ok:true});});
app.get('/api/local/spaces',(_req,res)=>res.json([space]));
app.get('/api/local/spaces/:id',(_req,res)=>res.json(space));
app.get('/api/local/spaces/:id/members',(_req,res)=>res.json([{userId:'browser-test',name:'Browser Test',role:'owner',isOwner:true}]));
app.get('/api/local/tasks',(_req,res)=>res.json({tasks}));
app.get('/api/local/tasks/:id', (req,res)=>res.json({task:tasks.find(task=>task.id===Number(req.params.id))}));
app.get('/api/local/tasks/:id/overview-metrics',(_req,res)=>res.json({metrics:{collecting:false,toolCalls:null,compactions:null,activeDurationMs:null,workerId:null,workerName:null,model:null,decodeTps:null,decodeTpsMeasuredAt:null,jobId:null,jobStatus:null}}));
app.get('/api/local/tasks/:id/comments',(_req,res)=>res.json({comments:[]}));
app.get('/api/local/tasks/:id/movement-history',(_req,res)=>res.json({events:[]}));
app.get('/api/local/tasks/:id/executions',(_req,res)=>res.json({executions:[],active:false}));
app.get('/api/local/tasks/:id/files',(_req,res)=>res.json({basePath:'',path:'',entries:[],truncated:false}));
app.get('/api/local/spaces/:id/files',(_req,res)=>res.json({basePath:'',path:'',entries:[],truncated:false}));
app.get('/api/local/tasks/:id/console/status',(_req,res)=>res.json({active:false}));
app.get('/api/local/browser/sessions/task-session/:id',(_req,res)=>res.json({available:false}));
app.get('/api/pieces',(_req,res)=>res.json({pieces:[]}));
app.get('/api/pieces/:name',(req,res)=>res.json({piece:{name:req.params.name,description:'',movements:[],max_movements:1,initial_movement:''},source:'builtin'}));
app.get('/api/users/me/pets',(_req,res)=>res.json({pets:[],settings:{enabled:false,activePetId:null,size:48,position:'bottom-right',sound:false,reducedMotion:true,toolSparkEnabled:false,workerPets:{}}}));
app.get('/api/notifications/capabilities',(_req,res)=>res.json({}));
app.use('/api',(_req,res)=>res.status(404).json({error:'Not included in the local browser fixture'}));
app.use('/ui',express.static(root+'/ui/dist'));
app.get('/ui/*',(_req,res)=>res.sendFile(root+'/ui/dist/index.html'));
const server=createServer(app);
const stop=attachTranscriptionWs(server,{authActive:true,authenticateUpgrade:async()=>({id:'browser-test',role:'admin'}),getTools:()=>tools});
server.listen(0,'127.0.0.1');await once(server,'listening');
const base=`http://127.0.0.1:${server.address().port}`;
let browser, page;
const errors=[];
try{
browser=await chromium.launch({headless:true,args:['--no-sandbox','--use-fake-device-for-media-stream','--use-fake-ui-for-media-stream','--autoplay-policy=no-user-gesture-required']});
const context=await browser.newContext({viewport:{width:1440,height:1000},permissions:['microphone']});
await context.addInitScript(()=>{
localStorage.setItem('maestro.lang','ja');
navigator.mediaDevices.getDisplayMedia=async()=>{
const ctx=new AudioContext(); const oscillator=ctx.createOscillator(); const output=ctx.createMediaStreamDestination();oscillator.connect(output);oscillator.start();
window.__desktopAudioContext=ctx; return output.stream;
};
});
page=await context.newPage(); page.on('pageerror',e=>errors.push(e.message));
await page.goto(base+'/ui/?page=transcription');
await page.getByText(/文字起こしはチャット右側/).waitFor();
await page.goto(base+'/ui/?space=browser-space&chat=1&tab=transcription');
const dockA=page.locator('[data-transcription-task="1"]');
await dockA.getByRole('heading',{name:/^(文字起こし|Transcription)$/}).waitFor();
await page.getByRole('button',{name:'設定を開く',exact:true}).click();
await page.getByPlaceholder('ws://gpu-server:8000/v1/audio/stream').fill(configuredEndpoint);
await page.locator('#translation-server-url').fill(translationEndpoint);
await page.locator('#translation-model').fill('test-translategemma');
const savedConfig=page.waitForResponse(r=>r.url().endsWith('/api/config') && r.request().method()==='PUT');
await page.getByRole('button',{name:/保存して反映|Save and apply/i}).click();
assert.equal((await savedConfig).status(),200);
await page.getByRole('button',{name:/^(ワークスペース|Workspaces)$/}).first().click();
if(!await dockA.isVisible()) await page.getByTestId('space-chat-tab-transcription').click();
await dockA.getByRole('heading',{name:/^(文字起こし|Transcription)$/}).waitFor();
await page.getByLabel('原文言語').selectOption('ja');
await page.getByLabel('翻訳先言語').selectOption('en');
await page.getByRole('checkbox',{name:'逐次翻訳',exact:true}).check();
await page.getByRole('button',{name:'開始',exact:true}).click();
await page.getByText('文字起こし中',{exact:true}).waitFor();
await page.waitForFunction(()=>[...document.querySelectorAll('textarea')].some(x=>x.value.includes('途中結果')));
await page.getByText('Translated: 議題は AAO の文字起こし機能です。',{exact:true}).first().waitFor();
const before=audioFrames;
// Navigate away via actual App navigation, then return; capture stays mounted.
await page.screenshot({path:'/tmp/aao-transcription-live-check.png',fullPage:true});
await page.getByRole('button',{name:/^(ヘルプ|Help)$/}).first().click();
await page.waitForTimeout(800);
assert(audioFrames>before,'audio continues on another tab');
await page.getByRole('button',{name:'録音中のチャットを開く',exact:true}).click();
await dockA.getByText('文字起こし中',{exact:true}).waitFor();
// Change chat through the real workspace list: A must keep recording and B must not acquire audio.
const expand=page.getByTestId('space-chat-list-expand');
if(await expand.isVisible()) await expand.click();
await page.locator('[data-testid="space-chat-row"][data-task-id="2"]').getByText('会議 B',{exact:true}).click();
const dockB=page.locator('[data-transcription-task="2"]');
if(!await dockB.isVisible()) await page.getByTestId('space-chat-tab-transcription').click();
await dockB.getByRole('heading',{name:/^(文字起こし|Transcription)$/}).waitFor();
assert(await dockB.getByRole('button',{name:'開始',exact:true}).isDisabled(),'chat B cannot start while A records');
const beforeB=audioFrames;await page.waitForTimeout(700);assert(audioFrames>beforeB,'A audio continues while B is visible');
const firstSave=page.waitForResponse(r=>r.url().endsWith('/api/transcription/recordings') && r.request().method()==='POST');
await page.getByRole('button',{name:'録音を停止して保存',exact:true}).click();
const firstStored=await (await firstSave).json();
assert.equal(firstStored.recording.taskId,1);
assert(firstStored.workspaceFiles && !firstStored.workspaceError,'original transcript is also written into A workspace');
assert.equal(finals,2); assert(audioFrames>0);
assert(await dockB.isVisible(),'global stop keeps chat B displayed');
await page.getByRole('button',{name:'録音を停止して保存',exact:true}).waitFor({state:'hidden'});
assert(await dockB.getByRole('button',{name:'開始',exact:true}).isEnabled(),'finishing hidden A releases the recording lock');
await page.locator('[data-testid="space-chat-row"][data-task-id="1"]').getByText('会議 A',{exact:true}).click();
if(!await dockA.isVisible()) await page.getByTestId('space-chat-tab-transcription').click();
await dockA.getByRole('heading',{name:/^(文字起こし|Transcription)$/}).waitFor();
assert.equal(await page.locator('.transcription-transcript article').count(),2);
await page.waitForFunction(()=>{
const pairs=[...document.querySelectorAll('.transcription-pair')];
return pairs.length===4 && pairs.every(p=>p.textContent.includes('翻訳済み'));
});
await page.getByText('未保存の変更があります。',{exact:true}).waitFor();
const savedTranslation=page.waitForResponse(r=>r.url().endsWith('/api/transcription/recordings') && r.request().method()==='POST');
await page.getByRole('button',{name:'保存 / 再試行',exact:true}).click();
const saveResult=await (await savedTranslation).json();const stored=saveResult.recording;
assert.equal(stored.taskId,1);assert(!saveResult.workspaceError);
const taskARecordings=await (await context.request.get(base+'/api/transcription/recordings?taskId=1')).json();
const taskBRecordings=await (await context.request.get(base+'/api/transcription/recordings?taskId=2')).json();
assert.equal(taskARecordings.recordings.length,1);assert.equal(taskBRecordings.recordings.length,0);
const fileJson=JSON.parse(readFileSync(resolve(tasks[0].workspacePath,saveResult.workspaceFiles.json),'utf8'));
assert.deepEqual(fileJson.entries,stored.entries);assert.equal(fileJson.taskId,1);
const fileText=readFileSync(resolve(tasks[0].workspacePath,saveResult.workspaceFiles.txt),'utf8');
assert(fileText.includes('Translated:') && fileText.includes('原文') && fileText.includes('改訂'));
assert.equal(readdirSync(tasks[1].workspacePath).length,0,'B workspace remains untouched');
assert(provenance.some(entry=>entry.taskId===1 && entry.relPath.endsWith('.json')));
assert(stored.entries.every(e=>e.blocks.every(b=>b.translation?.sourceRevision===b.revision && b.translation.sourceText===b.text)));
assert.equal(new Set(stored.entries.flatMap(e=>e.blocks.map(b=>b.id))).size,4);
await page.screenshot({path:'/tmp/aao-transcription-wide-check.png',fullPage:true});
await page.reload();
await page.locator('[data-transcription-task="1"] .transcription-recording button').first().click();
await page.locator('.transcription-transcript article').nth(1).waitFor();
assert.equal(await page.locator('.transcription-transcript article').count(),2);
assert.equal(await page.getByRole('checkbox',{name:'逐次翻訳',exact:true}).isChecked(),false);
assert.equal(await page.locator('.transcription-pair').count(),4);
assert.equal(await page.getByText('Translated: 次回までに動作を確認します。',{exact:true}).count(),2);
const downloaded=page.waitForEvent('download');await page.getByRole('button',{name:'TXT',exact:true}).click();await downloaded;
await page.setViewportSize({width:390,height:844});
await page.screenshot({path:'/tmp/aao-transcription-narrow-check.png',fullPage:true});
assert(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),'no horizontal overflow');
assert(await page.getByTestId('space-chat-tab-transcription').isVisible(),'transcription tab remains reachable on mobile');
console.log(JSON.stringify({audioFrames,finals,entries:2,translatedRequests:translatedRequests.length,taskId:1,workspaceFiles:saveResult.workspaceFiles,errors,screenshots:['/tmp/aao-transcription-wide-check.png','/tmp/aao-transcription-narrow-check.png']}));
assert.equal(errors.length,0);
}catch(error){console.error(JSON.stringify({url:page?.url(),errors,body:await page?.locator('body').innerText().catch(()=> '')}));await page?.screenshot({path:'/tmp/aao-transcription-failure.png',fullPage:true}).catch(()=>{});throw error;}finally{await browser?.close();stop();server.closeAllConnections();server.close();upstream.clients.forEach(s=>s.terminate());upstream.close();translationServer.closeAllConnections();translationServer.close();db.close();rmSync(fixtureRoot,{recursive:true,force:true});}