Release Android Preview 21 and bilingual changelog

Add permission-based reading of Android contacts, SMS and call history. Include shared Gaia fixes and document device validation limits. Pixel image qualification remains pending.
This commit is contained in:
OverSu 2026-10-14 13:13:46 +02:00
commit 200d1fb235
43 changed files with 2271 additions and 65 deletions

View file

@ -3,10 +3,10 @@ import assert from 'node:assert/strict';
import vm from 'node:vm';
import {readFileSync} from 'node:fs';
const source=readFileSync(new URL('../android/extension/content.js',import.meta.url),'utf8');
async function setup(enabled=true) {
const sent=[],events=new Map();let receive;
async function setup(enabled=true,{defer=false,activeElement}={}) {
const sent=[],events=new Map(),timers=[];let receive,disconnect;
const page={navigator:{},dispatchEvent(){}};
const port={postMessage:m=>sent.push(m),onMessage:{addListener:fn=>receive=fn},onDisconnect:{addListener(){}}};
const port={postMessage:m=>sent.push(m),onMessage:{addListener:fn=>receive=fn},onDisconnect:{addListener:fn=>disconnect=fn}};
class Editor {
constructor(){this.editing=false;}
focus(e){this.inputElement=e;}
@ -14,14 +14,14 @@ async function setup(enabled=true) {
edit(){return this.state();}
}
const context={InputEditor:Editor,browser:{runtime:{connect:()=>port}},console,
location:{port:'18765',hostname:'sms.localhost'},document:{hidden:false},
location:{port:'18765',hostname:'sms.localhost'},document:{hidden:false,activeElement,hasFocus:()=>true},
window:{wrappedJSObject:page,Promise,Error,Event,CustomEvent:class extends Event{constructor(name,p){super(name);this.detail=p.detail;}}},
cloneInto:x=>x,exportFunction:(fn,obj,p)=>{obj[p.defineAs]=fn;},
addEventListener:(name,fn)=>events.set(name,fn),setTimeout,clearTimeout};
addEventListener:(name,fn)=>events.set(name,fn),setTimeout:fn=>timers.push(fn),clearTimeout(){}};
vm.runInNewContext(source,context);
receive({id:sent[0].id,reply:{result:enabled}});
if(!defer) receive({id:sent[0].id,reply:{result:enabled}});
await new Promise(resolve=>setImmediate(resolve));
return {sent,events,page,receive,context};
return {sent,events,page,receive,context,timers,disconnect};
}
function field(mode='numeric') {
const attrs=new Map([['inputmode',mode]]);
@ -51,3 +51,54 @@ test('Android keyboard remains the default and hidden Gaia restores the editor m
assert.equal(e.getAttribute('inputmode'),'numeric');
assert.equal(s.sent.at(-1).inputFocus,null);
});
test('a field focused before the preference arrives activates Gaia without another tap',async()=>{
const e=field(),s=await setup(true,{defer:true,activeElement:e});
s.events.get('focusin')({target:e});
assert.equal(s.sent.length,1);
s.receive({id:s.sent[0].id,reply:{result:true}});
await new Promise(resolve=>setImmediate(resolve));
assert.equal(e.getAttribute('inputmode'),'none');
assert.equal(s.sent.at(-1).inputFocus.inputMode,'numeric');
});
test('an old preference reply cannot undo a newer keyboard toggle',async()=>{
const e=field(),s=await setup(false,{defer:true,activeElement:e});
s.receive({event:{type:'input-mode',data:{enabled:true}}});
s.receive({id:s.sent[0].id,reply:{result:false}});
await new Promise(resolve=>setImmediate(resolve));
assert.equal(s.page.VulpesNativeInput,true);
assert.equal(e.getAttribute('inputmode'),'none');
});
test('returning to an app restores its focused Gaia editor',async()=>{
const e=field(),s=await setup(true,{activeElement:e});
s.context.document.hidden=true;s.events.get('visibilitychange')();
assert.equal(s.sent.at(-1).inputFocus,null);
s.context.document.hidden=false;s.events.get('visibilitychange')();
assert.equal(s.sent.at(-1).inputFocus.inputMode,'numeric');
assert.equal(e.getAttribute('inputmode'),'none');
});
test('bridge reconnection rereads the preference and restores the active editor',async()=>{
const e=field(),s=await setup(true,{activeElement:e});
s.disconnect();
assert.equal(e.getAttribute('inputmode'),'numeric');
assert.equal(s.page.VulpesNativeInput,false);
s.timers.at(-1)();
const request=s.sent.at(-1);
assert.equal(request.request.method,'input.config');
s.receive({id:request.id,reply:{result:true}});
await new Promise(resolve=>setImmediate(resolve));
assert.equal(e.getAttribute('inputmode'),'none');
assert.equal(s.sent.at(-1).inputFocus.inputMode,'numeric');
});
test('a background app cannot reclaim the keyboard when Android resumes',async()=>{
const e=field(),s=await setup(true,{activeElement:e});
s.context.document.hidden=true;s.events.get('visibilitychange')();
s.context.document.hidden=false;s.context.document.hasFocus=()=>false;
s.events.get('visibilitychange')();
assert.equal(s.sent.at(-1).inputFocus,null);
assert.equal(e.getAttribute('inputmode'),'numeric');
});

View file

@ -0,0 +1,59 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import {Contacts} from '../host/Contacts.sys.mjs';
import {Messages} from '../host/Messages.sys.mjs';
const source=readFileSync('android/extension/personal-data.mjs','utf8')
.replace("'./Contacts.mjs'",JSON.stringify(new URL('../host/Contacts.sys.mjs',import.meta.url).href))
.replace("'./Messages.mjs'",JSON.stringify(new URL('../host/Messages.sys.mjs',import.meta.url).href));
const {AndroidPersonalData,authorizePersonal,smsFromAndroid}=await import('data:text/javascript;base64,'+Buffer.from(source).toString('base64'));
const app={id:'communications',manifest:{permissions:{contacts:{access:'readwrite'}}}};
const smsApp={id:'sms',manifest:{permissions:{sms:{},contacts:{access:'readonly'}}}};
async function setup(){
const data=new Map([['vulpes-private-contacts',{revision:1,contacts:[{id:'local-1',name:['Local']}]}],['vulpes-private-messages',[{id:100,threadId:200,timestamp:1,body:'Local',delivery:'sent',receiver:'5550100',type:'sms',read:true}]]]);
const storage={run:async(name,op,args)=>{if(op==='get')return {value:structuredClone(data.get(name))};data.set(name,structuredClone(args[0]));}};
const contacts=new Contacts(storage,()=>{});await contacts.init();
const calls=[];let allowed=true;
const service=new AndroidPersonalData(async p=>{calls.push(p);if(p.operation==='status')return {contacts:allowed,sms:allowed};return {rows:p.kind==='contacts'?[{_id:1,display_name:'Android',tel:[{value:'5550199'}]}]:[{_id:1,thread_id:2,address:'5550199',body:'Android SMS',date:2,type:1,read:0,status:-1}],next:null};},contacts,new Messages(storage),storage);
return {service,data,calls,revoke(){allowed=false;}};
}
test('native contact reads preserve the local address book and search phone numbers',async()=>{
const s=await setup();const rows=await s.service.contactRequest(app,{operation:'all'});
assert.deepEqual(rows.map(r=>r.id),['local-1','android:1']);
assert.equal((await s.service.contactRequest(app,{operation:'find',options:{filterBy:['tel'],filterOp:'match',filterValue:'0199'}}))[0].id,'android:1');
assert.equal(s.data.get('vulpes-private-contacts').contacts.length,1);
s.revoke();assert.equal((await s.service.contactRequest(app,{operation:'all'})).length,1);
});
test('native messages and threads remain distinct from local IDs and are not copied to storage',async()=>{
const s=await setup();const rows=await s.service.messageRequest(smsApp,{operation:'getThreads'});
assert.deepEqual(rows.map(r=>r.id),[-2,200]);
const [sms]=await s.service.messageRequest(smsApp,{operation:'getMessages',args:[{threadId:-2}]});assert.equal(sms.id,-1);assert.equal(sms.body,'Android SMS');
assert.equal(s.data.get('vulpes-private-messages').length,1);
s.revoke();assert.equal((await s.service.messageRequest(smsApp,{operation:'getThreads'})).length,1);
});
test('Android writes never fall through to the local store or report false success',async()=>{
const s=await setup();
for(const params of [{operation:'save',contact:{id:'android:1'}},{operation:'remove',id:'android:1'},{operation:'clear'}]) await assert.rejects(s.service.contactRequest(app,params),/READ_ONLY/);
for(const params of [{operation:'delete',args:[[100,-1]]},{operation:'markMessageRead',args:[-1,true]}])await assert.rejects(s.service.messageRequest(smsApp,params),/READ_ONLY/);
assert.equal(s.data.get('vulpes-private-messages').length,1);
});
test('untrusted installed apps cannot reuse the Android permission granted to Gaia',async()=>{
const s=await setup();
assert.equal((await s.service.contactRequest({...app,installed:true},{operation:'all'})).length,1);
assert.equal((await s.service.messageRequest({...smsApp,installed:true},{operation:'getMessages'})).length,1);
assert.equal(s.calls.length,0);
assert.throws(()=>authorizePersonal({...app,installed:true},'contacts','page'),/PERMISSION/);
assert.throws(()=>authorizePersonal(smsApp,'calls','page'),/PERMISSION/);
assert.throws(()=>authorizePersonal(app,'contacts','permission'),/PERMISSION/);
assert.doesNotThrow(()=>authorizePersonal({id:'settings'},'sms','permission'));
});
test('provider errors and stalled page cursors are surfaced, not presented as an empty book',async()=>{
const s=await setup();let n=0;
s.service.native=async p=>p.operation==='status'?{contacts:true}:{rows:[],next:++n===1?1:1};
await assert.rejects(s.service.rows('contacts'),/INVALID_CURSOR/);
s.service.native=async()=>{throw Error('denied');};await assert.rejects(s.service.rows('contacts'),/denied/);
});
test('SMS mapping does not turn pending or failed sends into delivered messages',()=>{
for(const [type,delivery] of [[1,'received'],[2,'sent'],[4,'sending'],[5,'error'],[6,'sending']])assert.equal(smsFromAndroid({_id:1,thread_id:1,type}).delivery,delivery);
assert.throws(()=>smsFromAndroid({_id:Infinity,thread_id:1}),/INVALID_ANDROID_ID/);
});

View file

@ -0,0 +1,85 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import {readFileSync} from 'node:fs';
function setup(app,path,call){
const events=new Map(),notices=[];
const context={console,URL,Map,Set,Number,JSON,Date,Promise,queueMicrotask,setTimeout,clearTimeout,setInterval,clearInterval,
location:{hostname:app+'.localhost',pathname:path},navigator:{language:'en'},localStorage:{removeItem(){}},
document:{hidden:false,documentElement:{lang:'en'},readyState:'complete',addEventListener:(type,fn)=>events.set('document-'+type,fn),querySelectorAll(){return[];}},
MutationObserver:class{observe(){}},addEventListener:(type,fn)=>events.set(type,fn),
VulpesCompat:{call},VulpesGaiaUI:{notice:value=>notices.push(value)}};
context.window=context;vm.runInNewContext(readFileSync('android/ui/android-ui.js','utf8'),context);
return {context,events,notices};
}
test('native call rows use Gaia grouping and expose details without changing the local DB',async()=>{
const s=setup('communications','/dialer/index.html',async(_method,p)=>p.operation==='status'?{calls:true}:[
{_id:1,date:1000,number:'5550199',duration:42,type:2},
{_id:2,date:2000,number:'5550199',duration:0,type:2}]);
let localReads=0;
s.context.CallLogDBManager={getGroupList(){localReads++;},getGroup(){localReads++;},getGroupAtPosition(){localReads++;},
_getGroupId:r=>[0,r.number,r.type],_getGroupObject:g=>({...g,id:g.id.join('-'),type:g.id[2]})};
const db=s.context.CallLogDBManager;
const rows=await new Promise(resolve=>db.getGroupList(resolve,'lastEntryDate',true,false));
assert.equal(rows.length,1);assert.equal(rows[0].retryCount,2);assert.equal(rows[0].calls[1].duration,42000);
assert.equal((await db.getGroup('5550199',1000,'dialing')).calls.length,2);assert.equal(localReads,0);
const streamed=[];
await new Promise(resolve=>db.getGroupList(c=>{if(!c.value)return resolve();streamed.push(c.value);c.continue();},'lastEntryDate',true,true));
assert.equal(streamed.length,1);
});
test('SMS provider change refreshes the open conversation without reloading or touching the draft',async()=>{
const inserted=[];
const s=setup('sms','/index.html',async()=>[{id:-1,threadId:-2,body:'Received',timestamp:1000}]);
s.context.Navigation={isCurrentPanel:name=>name==='thread'};
s.context.ConversationView={activeThread:{id:-2},appendMessage:async message=>inserted.push(message)};
s.context.Compose={getText:()=> 'Unsent draft'};
s.context.document.getElementById=()=>null;
s.context.location.reload=()=>{throw Error('draft lost');};
s.events.get('vulpes-service-event')({detail:{type:'android-data-changed',data:{kind:'sms'}}});
await new Promise(resolve=>setImmediate(resolve));
assert.equal(inserted.length,1);assert.equal(inserted[0].body,'Received');
assert.equal(s.context.Compose.getText(),'Unsent draft');
});
test('hidden call log refreshes contact names when shown after Android access changes',()=>{
const s=setup('communications','/dialer/index.html',async()=>({}));
let updates=0;
s.context.CallLog={sticky:{},_contactCache:true,updateListWithContactInfo(){updates++;}};
s.context.document.hidden=true;
s.events.get('vulpes-service-event')({detail:{type:'android-data-changed',data:{kind:'contacts'}}});
assert.equal(updates,0);
s.context.document.hidden=false;
s.events.get('visibilitychange')();
assert.equal(updates,1);
assert.equal(s.context.CallLog._contactCache,false);
});
test('Android contact HTML caches are disabled before Gaia can apply stale names',async()=>{
const s=setup('communications','/contacts/index.html',async()=>({}));
try {
let writes=0;
s.context.Cache={get enabled(){return true;},get active(){return true;},set firstChunk(value){writes++;},apply:async()=> 'stale names'};
assert.equal(s.context.Cache.enabled,false);
assert.equal(s.context.Cache.active,false);
s.context.Cache.firstChunk={cache:'Android names'};
assert.equal(writes,0);
await assert.rejects(s.context.Cache.apply());
} finally{s.events.get('pagehide')();}
});
test('revoked native contact details close when the hidden app becomes visible',async()=>{
const s=setup('communications','/contacts/index.html',async()=>[]);
try {
let closed=0;
s.context.MainNavigation={currentView:()=> 'view-contact-details'};
s.context.Contacts={goBack(){closed++;}};
s.events.get('document-click')({target:{closest:()=>({dataset:{uuid:'android:1'}})}});
s.context.document.hidden=true;
s.events.get('vulpes-service-event')({detail:{type:'android-data-changed',data:{kind:'contacts'}}});
assert.equal(closed,0);
s.context.document.hidden=false;
s.events.get('visibilitychange')();
await new Promise(resolve=>setImmediate(resolve));
assert.equal(closed,1);
} finally{s.events.get('pagehide')();}
});

View file

@ -0,0 +1,20 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import {readFileSync} from 'node:fs';
for (const file of ['gaia/apps/communications/dialer/js/call_log.js',
'assets/webapps/communications.gaiamobile.org/dialer/js/call_log.js']) {
test(`call log resolves contacts in its newly rendered section: ${file}`, () => {
const context = {navigator:{mozContacts:{}},window:{}};
vm.runInNewContext(readFileSync(file,'utf8'),context);
const log = context.CallLog, items = [], lookedUp = [];
const list = {appendChild:row=>items.push(row),querySelectorAll:()=>items};
log.createSectionForGroup=()=>({getElementsByTagName:()=>[list]});
log.createGroup=group=>({dataset:{phoneNumber:group.number}});
log.callLogContainer={appendChild(){}};
log._updateContact=(_node,number)=>lookedUp.push(number);
log.renderChunk([{number:'5550199'}]);
assert.deepEqual(lookedUp,['5550199']);
});
}

View file

@ -0,0 +1,24 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import {readFileSync} from 'node:fs';
const root=new URL('../',import.meta.url);
const source=readFileSync(new URL('gaia/apps/keyboard/js/settings/layout_dictionary_downloader.js',root),'utf8');
test('every offered Latin dictionary is present and has a valid header',()=>{
const layouts=JSON.parse(readFileSync(new URL('assets/webapps/keyboard.gaiamobile.org/js/settings/layouts.json',root)));
for(const name of new Set(layouts.filter(l=>l.imEngineId==='latin'&&l.dictFilename).map(l=>l.dictFilename))){
const data=readFileSync(new URL('gaia/apps/keyboard/js/imes/latin/dictionaries/'+name,root));
assert.equal(data.subarray(0,8).toString(),'FxOSDICT',name);
}
});
test('adding a language reads and verifies its local dictionary',async()=>{
let requested,aborted=false;
class XHR {
open(method,url){requested=url;}
send(){const b=readFileSync(new URL('gaia/apps/keyboard'+requested,root));this.response=b.buffer.slice(b.byteOffset,b.byteOffset+b.byteLength);this.onloadend();}
abort(){aborted=true;}
}
const window={};vm.runInNewContext(source,{window,XMLHttpRequest:XHR,ArrayBuffer,Uint8Array,Promise});
const data=await new window.LayoutDictionaryDownloader('latin','it.dict').load();
assert.equal(requested,'/js/imes/latin/dictionaries/it.dict');assert.ok(data.byteLength>8);assert.equal(aborted,false);
});

View file

@ -0,0 +1,40 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import {readFileSync} from 'node:fs';
const source=readFileSync(new URL('../host/gaia-input.js',import.meta.url),'utf8');
function keyboard() {
const listeners=new Map();let reply;
const parent={postMessage(){}};
const context={VulpesNativeInput:true,location:{hostname:'keyboard.localhost'},
VulpesCompat:{call:()=>new Promise(resolve=>reply=resolve)},
navigator:{},document:{readyState:'loading'},parent,console,Event,EventTarget,
CustomEvent:class extends Event {constructor(name,options){super(name);this.detail=options.detail;}},
addEventListener(name,fn){listeners.set(name,fn);},dispatchEvent(){}};
context.window=context;
vm.runInNewContext(source,context);
return {
ime:context.navigator.mozInputMethod,
state:value=>listeners.get('vulpes-service-event')({detail:{type:'input-focus',data:value}}),
reply:value=>reply(value),
viewport:()=>listeners.get('message')({source:parent,origin:'http://system.localhost:8765',
data:{type:'vulpes-keyboard-viewport',width:360,height:740}})
};
}
test('an old empty snapshot cannot clear a newly focused keyboard',async()=>{
const k=keyboard();k.viewport();k.state({id:2,text:'current'});k.reply(null);
await new Promise(resolve=>setImmediate(resolve));
assert.equal(k.ime.inputcontext.text,'current');
});
test('an old active snapshot cannot reopen a dismissed keyboard',async()=>{
const k=keyboard();k.viewport();k.state(null);k.reply({id:1,text:'old'});
await new Promise(resolve=>setImmediate(resolve));
assert.equal(k.ime.inputcontext,null);
});
test('focus received before viewport measurement survives a stale initial reply',async()=>{
const k=keyboard();k.state({id:3,text:'pending'});k.reply(null);
await new Promise(resolve=>setImmediate(resolve));
k.viewport();
assert.equal(k.ime.inputcontext.text,'pending');
});

View file

@ -0,0 +1,34 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import {readFileSync} from 'node:fs';
const source=readFileSync(new URL('../gaia/apps/marketplace/marketplace.js',import.meta.url),'utf8');
function element(){return Object.assign(new EventTarget(),{style:{},hidden:false,textContent:''});}
async function setup() {
const nodes=Object.fromEntries(['catalogue','message','confirm','open','browse','install','cancel','status','title','question','permissions'].map(id=>[id,element()]));
let poll,download=null,finish;
const context={document:{getElementById:id=>nodes[id]},navigator:{language:'en'},console,
addEventListener(){},setInterval:fn=>poll=fn,VulpesCompat:{call:async method=>{
if(method==='locale.current')return 'en';
if(method==='marketplace.poll'){const url=download;download=null;return url;}
if(method==='marketplace.prepare')return new Promise(resolve=>finish=resolve);
}}};
await vm.runInNewContext(source,context);
return {nodes,poll,setDownload:()=>download='https://vulpes-os.org/marketplace/index.php?download=1',finish:()=>finish({name:'Test',bytes:1024,token:'test'})};
}
test('closing the Android web view exposes a way back to the catalogue, not a blank frame',async()=>{
const {nodes:n}=await setup();
n.catalogue.dispatchEvent(new Event('mozbrowserclose'));
assert.equal(n.message.hidden,false);assert.equal(n.catalogue.style.visibility,'hidden');
assert.match(n.status.textContent,/reopen/);assert.equal(n.open.hidden,true);
n.browse.onclick();
assert.equal(n.message.hidden,true);assert.equal(n.catalogue.style.visibility,'visible');
assert.match(n.catalogue.src,/mode=app&lang=en/);
});
test('a close notification cannot erase an installation confirmation',async()=>{
const f=await setup();f.setDownload();const pending=f.poll();
await new Promise(resolve=>setImmediate(resolve));
f.nodes.catalogue.dispatchEvent(new Event('mozbrowserclose'));
assert.match(f.nodes.status.textContent,/Checking/);
f.finish();await pending;assert.equal(f.nodes.confirm.hidden,false);
});

View file

@ -0,0 +1,29 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import {readFileSync} from 'node:fs';
const source=readFileSync(new URL('../gaia/apps/settings/js/modules/panel_cache.js',import.meta.url),'utf8');
function fixture(load) {
let cache;
function require(id,ok,fail) {
if(Array.isArray(id))return load(ok,fail);
if(id==='shared/lazy_loader')return {load:(_,done)=>done()};
return ()=>({beforeShow(){}});
}
vm.runInNewContext(source,{define:factory=>cache=factory(require),navigator:{addIdleObserver(){}},
document:{getElementById:()=>({querySelector:()=>({dataset:{path:'panels/storage/panel'}})})}});
return cache;
}
for(const failure of ['dependency','constructor','invalid']) test('Panel cache reports '+failure+' failure without caching a broken panel',()=>{
let broken=true;
const expected=new Error('storage unavailable');
const cache=fixture((ok,fail)=>{
if(!broken)return ok(()=>({beforeShow(){}}));
if(failure==='dependency')return fail(expected);
ok(()=>{if(failure==='constructor')throw expected;return {};});
});
let calls=0;
cache.get('mediaStorage',(panel,error)=>{calls++;assert.equal(panel,null);assert.ok(error);});
assert.equal(calls,1);broken=false;
cache.get('mediaStorage',(panel,error)=>{assert.equal(error,undefined);assert.equal(typeof panel.beforeShow,'function');});
});

View file

@ -0,0 +1,19 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import vm from 'node:vm';
import {readFileSync} from 'node:fs';
const source=readFileSync(new URL('../gaia/apps/settings/js/startup.js',import.meta.url),'utf8');
const bootstrap=source.slice(source.lastIndexOf('(function() {'));
test('Settings waits for deferred components before loading AMD modules',()=>{
let starts=0;
const document=Object.assign(new EventTarget(),{readyState:'interactive'});
vm.runInNewContext(bootstrap,{document,AppStarter:()=>({start:()=>starts++})});
assert.equal(starts,0);
document.dispatchEvent(new Event('DOMContentLoaded'));assert.equal(starts,1);
document.dispatchEvent(new Event('DOMContentLoaded'));assert.equal(starts,1);
});
test('Settings starts immediately when loaded after the document',()=>{
let starts=0;
vm.runInNewContext(bootstrap,{document:{readyState:'complete'},AppStarter:()=>({start:()=>starts++})});
assert.equal(starts,1);
});

View file

@ -74,7 +74,7 @@ class ChangelogTests(unittest.TestCase):
def test_date_first_heading_and_platform_badges(self):
for language in export.LANGUAGES:
entry = self.languages[language][0]
entry = next(e for e in self.languages[language] if e['id'] == '2.7-preview.20')
self.assertEqual(entry['id'], '2.7-preview.20')
self.assertEqual(entry['date'], '2026-10-13')
common = next(g for g in entry['groups'] if g['common'])
@ -98,7 +98,7 @@ class ChangelogTests(unittest.TestCase):
def test_release_summary_follows_the_entry_without_copying_text(self):
tag = 'v2.7-preview.20'
self.source['entries'][0]['text']['fr'] += '\nA new correction.\n'
next(e for e in self.source['entries'] if e['id'] == '2.7-preview.20')['text']['fr'] += '\nA new correction.\n'
docs = export.release_documents(self.source, tag)
self.assertIn('A new correction.', docs['release-body.md'])