mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-29 17:22:25 +01:00
403 lines
21 KiB
JavaScript
403 lines
21 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFile } from 'node:fs/promises';
|
|
import { chromium } from 'playwright';
|
|
|
|
test('Chat Photo Editor Action Integrity and Turn Scoping', async () => {
|
|
const browser = await chromium.launch({ headless: true });
|
|
try {
|
|
const page = await browser.newPage();
|
|
const serverResponses = new Map();
|
|
const recordedUploadRequests = [];
|
|
|
|
await page.route('http://photo-test.local/**', async (route) => {
|
|
const url = new URL(route.request().url());
|
|
const path = url.pathname;
|
|
|
|
if (path === '/') {
|
|
return route.fulfill({
|
|
contentType: 'text/html',
|
|
body: `<!DOCTYPE html>
|
|
<html>
|
|
<body>
|
|
<div id="toast"></div>
|
|
<div id="chat-history"></div>
|
|
</body>
|
|
</html>`,
|
|
});
|
|
}
|
|
|
|
if (path.startsWith('/api/upload/')) {
|
|
const id = path.replace('/api/upload/', '');
|
|
const method = route.request().method();
|
|
recordedUploadRequests.push({ id, method });
|
|
const status = serverResponses.get(id) || 200;
|
|
if (status === 200) {
|
|
return route.fulfill({
|
|
status: 200,
|
|
headers: {
|
|
'Content-Type': 'image/png',
|
|
'Content-Length': '16',
|
|
'X-Content-Type-Options': 'nosniff',
|
|
},
|
|
body: method === 'HEAD' ? Buffer.alloc(0) : Buffer.from('fake-image-bytes'),
|
|
});
|
|
}
|
|
return route.fulfill({
|
|
status,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ detail: 'Upload error or forbidden' }),
|
|
});
|
|
}
|
|
|
|
try {
|
|
const source = await readFile(new URL('..' + path, import.meta.url), 'utf8');
|
|
return route.fulfill({ contentType: 'text/javascript', body: source });
|
|
} catch (err) {
|
|
return route.abort();
|
|
}
|
|
});
|
|
|
|
await page.goto('http://photo-test.local/');
|
|
|
|
const results = await page.evaluate(async () => {
|
|
const box = document.getElementById('chat-history');
|
|
|
|
// Import chat.js to test hooks
|
|
await import('/static/js/chat.js');
|
|
const hooks = window.__chatPhotoEditorTestHooks;
|
|
if (!hooks) throw new Error('Test hooks not exposed on window.__chatPhotoEditorTestHooks');
|
|
|
|
const { _isAuthorizedImageUrl, _findOriginatingUserMessage, _attachedImageForTurn, renderPhotoEditorApplyButton } = hooks;
|
|
|
|
// --- Scenario 1: Authorization checks ---
|
|
const authValidUpload = _isAuthorizedImageUrl('/api/upload/img_123');
|
|
const authValidGallery = _isAuthorizedImageUrl('/api/gallery/img_123');
|
|
const authBlob = _isAuthorizedImageUrl('blob:http://photo-test.local/550e8400-e29b-41d4-a716-446655440000');
|
|
const authCrossOriginBlob = _isAuthorizedImageUrl('blob:https://evil.com/550e8400-e29b-41d4-a716-446655440000');
|
|
const authMalformedBlob = _isAuthorizedImageUrl('blob:javascript:alert(1)');
|
|
const pixel = document.createElement('canvas'); pixel.width = pixel.height = 1;
|
|
const authDataPng = _isAuthorizedImageUrl(pixel.toDataURL('image/png'));
|
|
const authDataJpeg = _isAuthorizedImageUrl(pixel.toDataURL('image/jpeg'));
|
|
const authDataWebp = _isAuthorizedImageUrl(pixel.toDataURL('image/webp'));
|
|
const authDataGif = _isAuthorizedImageUrl('data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7');
|
|
const authDataSvg = _isAuthorizedImageUrl('data:image/svg+xml;base64,PHN2Zz4=');
|
|
const authDataHtml = _isAuthorizedImageUrl('data:text/html;base64,PGgxPlRlc3Q=');
|
|
const authDataMalformed = _isAuthorizedImageUrl('data:image/png;base64,invalid!@#$');
|
|
const authDataOversized = _isAuthorizedImageUrl('data:image/png;base64,' + 'A'.repeat(11 * 1024 * 1024));
|
|
const authMaliciousDomain = _isAuthorizedImageUrl('https://evil.com/api/upload/img_123');
|
|
const authSchemeRelative = _isAuthorizedImageUrl('//evil.com/api/upload/img_123');
|
|
const authJavascript = _isAuthorizedImageUrl('javascript:alert(1)');
|
|
const authDirectoryTraversal = _isAuthorizedImageUrl('/api/upload/../../../etc/passwd');
|
|
const authEncodedTraversal = _isAuthorizedImageUrl('/api/upload/%2e%2e%2f');
|
|
|
|
// --- Scenario 2: Multi-turn isolation ---
|
|
// Build Turn 1: User attaches img_turn1
|
|
const user1 = document.createElement('div');
|
|
user1.className = 'msg msg-user';
|
|
user1.innerHTML = '<div class="body">Turn 1 text</div><div class="attach-image-preview" data-file-id="img_turn1"><img src="/api/upload/img_turn1" /></div>';
|
|
box.appendChild(user1);
|
|
|
|
const ai1 = document.createElement('div');
|
|
ai1.className = 'msg msg-ai round-holder';
|
|
ai1.innerHTML = '<div class="body">Turn 1 AI reply</div>';
|
|
box.appendChild(ai1);
|
|
|
|
// Build Turn 2: User attaches NO photo (just text)
|
|
const user2 = document.createElement('div');
|
|
user2.className = 'msg msg-user';
|
|
user2.innerHTML = '<div class="body">Turn 2 text without photo</div>';
|
|
box.appendChild(user2);
|
|
|
|
const ai2 = document.createElement('div');
|
|
ai2.className = 'msg msg-ai round-holder';
|
|
ai2.innerHTML = '<div class="body">Turn 2 AI reply</div>';
|
|
box.appendChild(ai2);
|
|
|
|
// Build Turn 3: User attaches img_turn3
|
|
const user3 = document.createElement('div');
|
|
user3.className = 'msg msg-user';
|
|
user3.innerHTML = '<div class="body">Turn 3 text</div><div class="attach-card" data-file-id="img_turn3"><img src="/api/upload/img_turn3" /></div>';
|
|
box.appendChild(user3);
|
|
|
|
const ai3 = document.createElement('div');
|
|
ai3.className = 'msg msg-ai round-holder';
|
|
ai3.innerHTML = '<div class="body">Turn 3 AI reply</div>';
|
|
box.appendChild(ai3);
|
|
|
|
// Resolve images for each turn
|
|
const turn1Resolved = _attachedImageForTurn(ai1, { kind: 'open_and_apply', prompt: 'edit turn 1' });
|
|
const turn2Resolved = _attachedImageForTurn(ai2, { kind: 'open_and_apply', prompt: 'edit turn 2' });
|
|
const turn3Resolved = _attachedImageForTurn(ai3, { kind: 'open_and_apply', prompt: 'edit turn 3' });
|
|
|
|
// Action with explicit imageId should honor explicit id
|
|
const explicitActionResolved = _attachedImageForTurn(ai2, { kind: 'open_and_apply', imageId: 'explicit_999' });
|
|
const filenameResolved = _attachedImageForTurn(ai2, { kind: 'open_and_apply', imageId: 'explicit_999.png' });
|
|
const invalidExplicitResolved = _attachedImageForTurn(ai1, { kind: 'open_and_apply', imageUrl: 'data:image/png;base64,=' });
|
|
|
|
// Action on container with no user message above
|
|
const detachedAi = document.createElement('div');
|
|
detachedAi.className = 'msg msg-ai';
|
|
const detachedResolved = _attachedImageForTurn(detachedAi, { kind: 'open_and_apply' });
|
|
|
|
return {
|
|
authValidUpload,
|
|
authValidGallery,
|
|
authBlob,
|
|
authCrossOriginBlob,
|
|
authMalformedBlob,
|
|
authDataPng,
|
|
authDataJpeg,
|
|
authDataWebp,
|
|
authDataGif,
|
|
authDataSvg,
|
|
authDataHtml,
|
|
authDataMalformed,
|
|
authDataOversized,
|
|
authMaliciousDomain,
|
|
authSchemeRelative,
|
|
authJavascript,
|
|
authDirectoryTraversal,
|
|
authEncodedTraversal,
|
|
turn1Resolved,
|
|
turn2Resolved,
|
|
turn3Resolved,
|
|
explicitActionResolved,
|
|
filenameResolved,
|
|
invalidExplicitResolved,
|
|
detachedResolved,
|
|
};
|
|
});
|
|
|
|
// Verify Scenario 1: Authorization
|
|
assert.equal(results.authValidUpload, true, 'Valid same-origin upload is authorized');
|
|
assert.equal(results.authValidGallery, true, 'Valid same-origin gallery is authorized');
|
|
assert.equal(results.authBlob, false, 'Same-origin alone does not authorize a blob URL');
|
|
assert.equal(results.authCrossOriginBlob, false, 'Cross-origin blob URL is rejected');
|
|
assert.equal(results.authMalformedBlob, false, 'Malformed blob URL is rejected');
|
|
assert.equal(results.authDataPng, true, 'PNG data URL is authorized');
|
|
assert.equal(results.authDataJpeg, true, 'JPEG data URL is authorized');
|
|
assert.equal(results.authDataWebp, true, 'WebP data URL is authorized');
|
|
assert.equal(results.authDataGif, true, 'GIF data URL is authorized');
|
|
assert.equal(results.authDataSvg, false, 'SVG data URL is rejected');
|
|
assert.equal(results.authDataHtml, false, 'HTML data URL is rejected');
|
|
assert.equal(results.authDataMalformed, false, 'Malformed data URL is rejected');
|
|
assert.equal(results.authDataOversized, false, 'Oversized data URL > 10MB is rejected');
|
|
assert.equal(results.authMaliciousDomain, false, 'External malicious domain rejected');
|
|
assert.equal(results.authSchemeRelative, false, 'Scheme-relative URL rejected');
|
|
assert.equal(results.authJavascript, false, 'Javascript scheme rejected');
|
|
assert.equal(results.authDirectoryTraversal, false, 'Directory traversal in path rejected');
|
|
assert.equal(results.authEncodedTraversal, false, 'Encoded directory traversal in path rejected');
|
|
|
|
// Verify Scenario 2: Multi-turn isolation
|
|
assert.equal(results.turn1Resolved, '/api/upload/img_turn1', 'Turn 1 AI correctly resolves Turn 1 user image');
|
|
assert.equal(results.turn2Resolved, null, 'Turn 2 AI resolves NULL because Turn 2 had no attached image');
|
|
assert.equal(results.turn3Resolved, '/api/upload/img_turn3', 'Turn 3 AI correctly resolves Turn 3 user image');
|
|
assert.equal(results.explicitActionResolved, '/api/upload/explicit_999', 'Explicit action imageId honored');
|
|
assert.equal(results.filenameResolved, '/api/upload/explicit_999.png', 'Canonical filenames keep their extensions');
|
|
assert.equal(results.invalidExplicitResolved, null, 'Invalid explicit data never falls back to a different image');
|
|
assert.equal(results.detachedResolved, null, 'Detached AI container without user message resolves to null');
|
|
|
|
// --- Scenario 3: Button click lifecycle (deleted upload, rapid click, open apply) ---
|
|
const clickResults = await page.evaluate(async () => {
|
|
const hooks = window.__chatPhotoEditorTestHooks;
|
|
const { renderPhotoEditorApplyButton } = hooks;
|
|
|
|
let editorCalls = [];
|
|
window.gePhotoEditorApply = async (opts) => {
|
|
editorCalls.push(opts);
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
};
|
|
|
|
let errorsShown = [];
|
|
const fakeUi = {
|
|
showError(msg) { errorsShown.push(msg); },
|
|
showToast(msg) {},
|
|
};
|
|
|
|
// Case A: Missing attachment on Turn 2
|
|
const ai2 = document.querySelectorAll('.msg-ai')[1];
|
|
renderPhotoEditorApplyButton(ai2, { kind: 'open_and_apply', prompt: 'test turn 2' });
|
|
const btn2 = ai2.querySelector('.chat-open-in-editor-btn');
|
|
btn2.click();
|
|
await new Promise((r) => setTimeout(r, 20));
|
|
const btn2DisabledAfterMissing = btn2.disabled;
|
|
|
|
// Case B: Deleted upload (server returns 404)
|
|
const userDel = document.createElement('div');
|
|
userDel.className = 'msg msg-user';
|
|
userDel.innerHTML = '<div class="attach-image-preview" data-file-id="img_deleted"><img src="/api/upload/img_deleted" /></div>';
|
|
document.getElementById('chat-history').appendChild(userDel);
|
|
|
|
const aiDel = document.createElement('div');
|
|
aiDel.className = 'msg msg-ai round-holder';
|
|
document.getElementById('chat-history').appendChild(aiDel);
|
|
|
|
renderPhotoEditorApplyButton(aiDel, { kind: 'open_and_apply', prompt: 'test deleted' });
|
|
const btnDel = aiDel.querySelector('.chat-open-in-editor-btn');
|
|
|
|
return {
|
|
hasBtn2: !!btn2,
|
|
btn2DisabledAfterMissing,
|
|
};
|
|
});
|
|
|
|
assert.equal(clickResults.hasBtn2, true);
|
|
assert.equal(clickResults.btn2DisabledAfterMissing, false, 'Button re-enabled after missing attachment');
|
|
|
|
// Set server status 404 for img_deleted
|
|
serverResponses.set('img_deleted', 404);
|
|
|
|
const deletedResults = await page.evaluate(async () => {
|
|
const aiDel = document.querySelectorAll('.msg-ai')[3];
|
|
const btnDel = aiDel.querySelector('.chat-open-in-editor-btn');
|
|
btnDel.click();
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
return {
|
|
disabled: btnDel.disabled,
|
|
};
|
|
});
|
|
|
|
assert.equal(deletedResults.disabled, false, 'Button re-enabled when server returns 404');
|
|
|
|
// Case C: Valid attachment on Turn 1 click launches editor
|
|
const validResults = await page.evaluate(async () => {
|
|
let editorCalls = [];
|
|
window.gePhotoEditorApply = async (opts) => {
|
|
editorCalls.push(opts);
|
|
};
|
|
|
|
const ai1 = document.querySelectorAll('.msg-ai')[0];
|
|
const { renderPhotoEditorApplyButton } = window.__chatPhotoEditorTestHooks;
|
|
renderPhotoEditorApplyButton(ai1, { kind: 'open_and_apply', prompt: 'make b&w' });
|
|
const btn1 = ai1.querySelector('.chat-open-in-editor-btn');
|
|
|
|
// Rapid clicks: trigger multiple synchronous clicks
|
|
btn1.click();
|
|
btn1.click();
|
|
btn1.click();
|
|
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
|
|
return {
|
|
editorCallsCount: editorCalls.length,
|
|
editorCall: editorCalls[0],
|
|
};
|
|
});
|
|
|
|
assert.equal(validResults.editorCallsCount, 1, 'Rapid clicks deduplicated, editor called exactly once');
|
|
assert.equal(validResults.editorCall.imageUrl, '/api/upload/img_turn1', 'Editor opened with Turn 1 image');
|
|
assert.equal(validResults.editorCall.prompt, 'make b&w');
|
|
|
|
// Case D: Verify HEAD probe was used for verification
|
|
const headProbeObserved = recordedUploadRequests.some((r) => r.id === 'img_turn1' && r.method === 'HEAD');
|
|
assert.equal(headProbeObserved, true, 'Chat-to-editor preflight executed an authorized HEAD probe');
|
|
|
|
// Case E: Unauthorized upload returns 403, surfaces error and re-enables button
|
|
serverResponses.set('img_forbidden', 403);
|
|
const forbiddenResults = await page.evaluate(async () => {
|
|
const userForbidden = document.createElement('div');
|
|
userForbidden.className = 'msg msg-user';
|
|
userForbidden.innerHTML = '<div class="attach-image-preview" data-file-id="img_forbidden"><img src="/api/upload/img_forbidden" /></div>';
|
|
document.getElementById('chat-history').appendChild(userForbidden);
|
|
|
|
const aiForbidden = document.createElement('div');
|
|
aiForbidden.className = 'msg msg-ai round-holder';
|
|
document.getElementById('chat-history').appendChild(aiForbidden);
|
|
|
|
const { renderPhotoEditorApplyButton } = window.__chatPhotoEditorTestHooks;
|
|
renderPhotoEditorApplyButton(aiForbidden, { kind: 'open_and_apply', prompt: 'forbidden test' });
|
|
const btnForb = aiForbidden.querySelector('.chat-open-in-editor-btn');
|
|
btnForb.click();
|
|
await new Promise((r) => setTimeout(r, 50));
|
|
|
|
return {
|
|
disabled: btnForb.disabled,
|
|
};
|
|
});
|
|
|
|
assert.equal(forbiddenResults.disabled, false, 'Button re-enabled when server returns 403');
|
|
} finally {
|
|
await browser.close();
|
|
}
|
|
});
|
|
|
|
test('editor actions validate raster bytes, dimensions, decode and reject all unverified blobs before launch', async () => {
|
|
const browser = await chromium.launch({ headless: true });
|
|
try {
|
|
const page = await browser.newPage();
|
|
const apiRequests = [];
|
|
await page.route('http://raster.local/**', async route => {
|
|
const path = new URL(route.request().url()).pathname;
|
|
if (path === '/') return route.fulfill({ contentType: 'text/html', body: '<div id="chat-history"></div>' });
|
|
if (path === '/api/gallery/owned') { apiRequests.push(path); return route.fulfill({ contentType: 'application/json', body: JSON.stringify({ id: 'owned', url: '/api/generated-image/owned.png' }) }); }
|
|
if (path === '/api/gallery/foreign') { apiRequests.push(path); return route.fulfill({ status: 404, body: '' }); }
|
|
if (path.startsWith('/api/upload/')) { apiRequests.push(path); return route.fulfill({ status: 200, headers: { 'Content-Type': 'image/png' }, body: '' }); }
|
|
if (path.startsWith('/api/')) return route.fulfill({ contentType: 'application/json', body: '{}' });
|
|
try { return route.fulfill({ contentType: 'text/javascript', body: await readFile(new URL('..' + path, import.meta.url), 'utf8') }); }
|
|
catch { return route.fulfill({ status: 404, body: '' }); }
|
|
});
|
|
await page.goto('http://raster.local/');
|
|
const results = await page.evaluate(async () => {
|
|
await import('/static/js/chat.js');
|
|
const { renderPhotoEditorApplyButton } = window.__chatPhotoEditorTestHooks;
|
|
const canvas = document.createElement('canvas'); canvas.width = canvas.height = 2;
|
|
canvas.getContext('2d').fillRect(0, 0, 2, 2);
|
|
const png = canvas.toDataURL('image/png');
|
|
const bytes = Uint8Array.from(atob(png.split(',')[1]), ch => ch.charCodeAt(0));
|
|
const encode = bytes => { let text = ''; for (const byte of bytes) text += String.fromCharCode(byte); return 'data:image/png;base64,' + btoa(text); };
|
|
const headerDimensions = (w, h) => { const b = bytes.slice(); const v = new DataView(b.buffer); v.setUint32(16, w); v.setUint32(20, h); return encode(b); };
|
|
const invalidDecode = bytes.slice(0, 33); // valid signature/dimensions but no pixel data
|
|
const ownBlob = URL.createObjectURL(new Blob([bytes], { type: 'image/png' }));
|
|
const revoked = URL.createObjectURL(new Blob([bytes], { type: 'image/png' })); URL.revokeObjectURL(revoked);
|
|
// This action intentionally has no blob registration API. Even application-created
|
|
// object URLs must use their authorized server attachment or raster data source.
|
|
const cases = [
|
|
['upload', '/api/upload/owned', true], ['gallery', '/api/gallery/owned', true],
|
|
['png', png, true], ['jpeg', canvas.toDataURL('image/jpeg'), true], ['webp', canvas.toDataURL('image/webp'), true],
|
|
['gif', 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', true],
|
|
['padding-one', 'data:image/png;base64,=', false], ['padding-length', 'data:image/png;base64,A', false],
|
|
['padding-middle', 'data:image/png;base64,AA=AA', false], ['padding-excess', 'data:image/png;base64,AAAA====', false], ['foreign-gallery', '/api/gallery/foreign', false],
|
|
['pad-bits', 'data:image/png;base64,AB==', false], ['bytes', 'data:image/png;base64,AAAA', false],
|
|
['mismatch', png.replace('image/png', 'image/jpeg'), false], ['truncated-image', encode(invalidDecode), false],
|
|
['svg', 'data:image/svg+xml;base64,PHN2Zy8+', false], ['html', 'data:text/html;base64,PGgxLz4=', false],
|
|
['svg-disguised', 'data:image/png;base64,PHN2Zy8+', false],
|
|
['foreign-blob', 'blob:https://foreign.invalid/deadbeef', false], ['arbitrary-blob', 'blob:http://raster.local/deadbeef', false],
|
|
['application-created-blob', ownBlob, false], ['revoked-blob', revoked, false],
|
|
['oversized-url', 'data:image/png;base64,' + 'A'.repeat(10 * 1024 * 1024), false],
|
|
['oversized-decoded-bytes', 'data:image/png;base64,' + 'A'.repeat(4 * Math.ceil((7 * 1024 * 1024 + 3) / 3)), false],
|
|
['dimension', headerDimensions(8193, 1), false], ['pixel-budget', headerDimensions(8192, 8192), false],
|
|
['scheme-relative', '//raster.local/api/upload/owned', false], ['encoded-traversal', '/api/upload/%2e%2e/owned', false],
|
|
];
|
|
const oldDecode = window.createImageBitmap; let decodes = 0;
|
|
window.createImageBitmap = (...args) => { decodes++; return oldDecode(...args); };
|
|
let launches = []; window.gePhotoEditorApply = async opts => launches.push(opts.imageUrl);
|
|
const output = [];
|
|
for (const [name, url, accepted] of cases) {
|
|
const host = document.createElement('div'); document.body.append(host);
|
|
renderPhotoEditorApplyButton(host, { kind: 'open_and_apply', imageUrl: url, prompt: 'synthetic edit' });
|
|
const btn = host.querySelector('button'); const before = launches.length; const decodeBefore = decodes;
|
|
btn.click(); btn.click(); btn.click();
|
|
while (btn.disabled) await new Promise(resolve => setTimeout(resolve, 5));
|
|
output.push({ name, accepted, launches: launches.length - before, decodes: decodes - decodeBefore }); host.remove();
|
|
}
|
|
// Cache reuse across repeated activation of the same complete inline image.
|
|
const host = document.createElement('div'); document.body.append(host);
|
|
renderPhotoEditorApplyButton(host, { kind: 'open_and_apply', imageUrl: png, prompt: 'repeat' });
|
|
const btn = host.querySelector('button'); const before = decodes;
|
|
for (let i = 0; i < 3; i++) { btn.click(); while (btn.disabled) await new Promise(resolve => setTimeout(resolve, 5)); }
|
|
const cachedDecodes = decodes - before;
|
|
URL.revokeObjectURL(ownBlob);
|
|
return { cases: output, cachedDecodes, launches };
|
|
});
|
|
assert.deepEqual(results.cases.filter(item => item.launches !== (item.accepted ? 1 : 0)).map(item => item.name), [], 'every raster authorization case must match');
|
|
for (const name of ['dimension', 'pixel-budget', 'oversized-url', 'oversized-decoded-bytes', 'bytes', 'mismatch']) {
|
|
assert.equal(results.cases.find(item => item.name === name).decodes, 0, name + ' rejected before browser allocation');
|
|
}
|
|
assert.equal(results.cases.find(item => item.name === 'truncated-image').decodes, 1, 'actual decoder rejects incomplete image');
|
|
assert.equal(results.cachedDecodes, 1, 'same image only decoded once for repeated activation');
|
|
assert.deepEqual(apiRequests, ['/api/upload/owned', '/api/gallery/owned', '/api/gallery/foreign']);
|
|
assert.ok(results.launches.includes('http://raster.local/api/generated-image/owned.png'));
|
|
} finally { await browser.close(); }
|
|
});
|