diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 992d326..96b2723 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -51,6 +51,9 @@ jobs: - name: Generar iconos (desde app-icon.png) run: npm run icons + - name: Probar el cliente + run: npm test + - name: Construir, firmar y publicar uses: tauri-apps/tauri-action@v0 env: @@ -63,6 +66,18 @@ jobs: tagName: ${{ github.ref_type == 'tag' && github.ref_name || 'v__VERSION__' }} releaseName: "Labstream OS Desktop __VERSION__" releaseBody: "Instaladores de escritorio de Labstream OS.\n\n- Windows: el archivo `.exe` (NSIS).\n- macOS: el archivo `.dmg` universal (Intel + Apple Silicon).\n\nActualización automática activada: las apps ya instaladas se actualizan solas al publicar una versión nueva." - releaseDraft: false + releaseDraft: true prerelease: false args: ${{ matrix.args }} + + activar: + needs: publish + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Publicar solo cuando ambos instaladores estan completos + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION=$(node -p "require('./package.json').version") + gh release edit "v$VERSION" --draft=false --latest diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 64a0aea..98d75c2 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -44,6 +44,9 @@ jobs: - name: Instalar dependencias run: npm install + - name: Probar el script del cliente + run: npm test + # build.rs de Tauri valida que existan los iconos que declara tauri.conf.json. - name: Generar iconos (desde app-icon.png) run: npm run icons diff --git a/README.md b/README.md index f11276c..aa1f555 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,28 @@ pestañas son webviews hijos `tab-*`. Shell ↔ Rust se hablan por eventos `ls-* El servidor objetivo se configura en `src-tauri/src/lib.rs` → constante `SERVER_URL`. +### Portadas autenticadas (1.14.1) + +Las imagenes conservan su URL HTTPS y se cargan con la sesion del webview, igual +que en el navegador. Ya no se reescriben a `lsthumb://`: el descargador Rust no +comparte las cookies y fallaba con miniaturas sin token o con token vencido. +Tampoco se modifica el DOM de las imagenes durante la hidratacion. + +Se usa la cache HTTP con los ETag y Cache-Control del servidor. Una respuesta +304 reutiliza los bytes descargados; no implica fabricar otro fotograma. El +cajon antiguo no se borra, pero no se usa para las nuevas peticiones de imagenes. +No cambia el almacenamiento de originales, la cache HLS de LabTem ni los datos offline. + +Google Docs, Sheets, Drive y el inicio de sesion de Google se abren en el navegador +del sistema. Google no admite OAuth en webviews controlados por una app; ademas, +WKWebView no debe anunciarse como Chrome. Se conserva el agente de usuario nativo +y no se borran cookies ni sesiones. Las hojas conectadas siguen disponibles como +tabla dentro del area contable. Al reiniciar se restauran solo pestanas de Labstream. + +Pruebas: `npm test`; con Playwright disponible en Node, +`node tests/images.browser.cjs` verifica sesion, 304, carga dinamica y lienzo. +La prueba de navegador usa un servidor local y una cookie ficticia, sin datos reales. + Salidas: - **Windows** → instalador `.exe` (NSIS). diff --git a/package-lock.json b/package-lock.json index 0d50372..01fd9f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "labstream-desktop", - "version": "1.4.0", + "version": "1.14.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "labstream-desktop", - "version": "1.4.0", + "version": "1.14.1", "devDependencies": { "@tauri-apps/cli": "^2" } diff --git a/package.json b/package.json index 0bf1be8..54c3582 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,10 @@ { "name": "labstream-desktop", - "version": "1.14.0", + "version": "1.14.1", "private": true, "description": "App de escritorio (Windows) que envuelve Labstream OS — cliente del servidor del NAS.", "scripts": { + "test": "node --test tests/images.test.cjs", "icons": "tauri icon app-icon.png", "dev": "npm run icons && tauri dev", "build": "npm run icons && tauri build", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f926608..f6ed9e6 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2366,7 +2366,7 @@ dependencies = [ [[package]] name = "labstream-desktop" -version = "1.14.0" +version = "1.14.1" dependencies = [ "active-win-pos-rs", "device_query", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f1ce527..3dfd2ab 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "labstream-desktop" -version = "1.14.0" +version = "1.14.1" description = "Cliente de escritorio de Labstream OS" authors = ["Labstream SAS"] edition = "2021" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7bcf2be..81031a7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -54,11 +54,7 @@ use tauri_plugin_opener::OpenerExt; const SERVER_URL: &str = "https://os.labstreamsas.com"; -// Los documentos del contador viven en Google (pestaña Contable). Se abren como PESTANAS -// de esta app, no en el navegador externo: asi el WebView guarda la sesion de Google del -// usuario (cookies persistentes) y no hay que iniciar sesion cada vez. La lista es cerrada -// a proposito: solo hosts propios de Google (docs/drive/sheets y accounts para el login). -// Todo lo demas sigue yendo al navegador del sistema. +// Google requiere el navegador del sistema para autenticar y editar con compatibilidad completa. const GOOGLE_HOSTS: &[&str] = &[ "docs.google.com", "drive.google.com", @@ -67,29 +63,29 @@ const GOOGLE_HOSTS: &[&str] = &[ "myaccount.google.com", ]; -// Cadena de navegador de escritorio. Sin esto, WebView2 se anuncia como control embebido y -// Google Sheets/Docs se quedan en blanco. Se declara por plataforma porque Google tambien -// mira la coherencia entre el sistema declarado y el resto de la cadena. -#[cfg(target_os = "windows")] -const UA_ESCRITORIO: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; -#[cfg(target_os = "macos")] -const UA_ESCRITORIO: &str = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; -#[cfg(not(any(target_os = "windows", target_os = "macos")))] -const UA_ESCRITORIO: &str = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; - fn is_google_host(host: &str) -> bool { let h = host.to_ascii_lowercase(); GOOGLE_HOSTS.iter().any(|d| h == *d || h.ends_with(&format!(".{}", d))) } fn es_pestana_interna(target: &Url, server: &Url) -> bool { - if target.origin() == server.origin() { - return true; + target.origin() == server.origin() +} + +#[cfg(test)] +mod navigation_tests { + use super::*; + + #[test] + fn only_labstream_is_an_embedded_tab() { + let server = Url::parse(SERVER_URL).unwrap(); + assert!(es_pestana_interna(&server.join("/revisiones/1").unwrap(), &server)); + for url in ["https://docs.google.com/document/d/1/edit", "https://accounts.google.com", "https://os.labstreamsas.com.evil.example", "http://os.labstreamsas.com"] { + assert!(!es_pestana_interna(&Url::parse(url).unwrap(), &server)); + } + assert!(is_google_host("docs.google.com")); + assert!(!is_google_host("docs.google.com.evil.example")); } - if target.scheme() != "https" { - return false; - } - target.host_str().map(is_google_host).unwrap_or(false) } const TAB_BAR_H: f64 = 40.0; // alto lógico de la barra de pestañas (chrome) const CHROME: &str = "chrome"; @@ -243,12 +239,7 @@ const INIT_JS_TPL: &str = r#" // descargar fallaban solo en la app de Windows, y no en Mac ni en el navegador. // En macOS estas dos líneas son un no-op: allí el script nunca ha corrido en subframes. try { if (window.top !== window.self) return; } catch (e) { return; } - // Hosts de Google que la app abre como pestana propia (pestana Contable). En esas - // paginas solo reportamos titulo a la barra; NO tocamos clics ni window.open para no - // romper el editor de Google. - var GOOGLE_RE = /^(?:.*\.)?(?:docs|drive|sheets|accounts|myaccount)\.google\.com$/i; - var esGoogle = GOOGLE_RE.test(location.hostname); - if (location.origin !== ORIGIN && !esGoogle) return; + if (location.origin !== ORIGIN) return; if (window.__lsShell) return; window.__lsShell = true; @@ -259,8 +250,7 @@ const INIT_JS_TPL: &str = r#" function abs(h) { try { return new URL(h, location.href); } catch (e) { return null; } } function isHttp(u) { return !!u && (u.protocol === 'http:' || u.protocol === 'https:'); } function isApp(u) { return isHttp(u) && u.origin === ORIGIN; } - function isGoogleUrl(u) { return isHttp(u) && GOOGLE_RE.test(u.hostname); } - function isInterno(u) { return isApp(u) || isGoogleUrl(u); } + function isInterno(u) { return isApp(u); } function isExt(u) { return isHttp(u) && !isInterno(u); } function emit(name, payload) { try { T.event.emit(name, payload); } catch (e) {} } function openExternal(url) { @@ -269,73 +259,10 @@ const INIT_JS_TPL: &str = r#" } function newTab(url) { emit('ls-new-tab', { url: url }); } - // ── Miniaturas por el CAJÓN de la app (lsthumb://) ── - // Dentro de la app de escritorio, las miniaturas de la cuadrícula de fotos se piden por el - // esquema lsthumb:// en vez de directo al servidor. Así las sirve el cajón local (Rust): del - // disco si ya están —sobreviven a cerrar la app, que es lo que se pedía— y del NAS solo la - // primera vez. Fuera de la app (navegador normal) este script no corre y todo sigue igual. - // - // Se reescribe SOLO la miniatura de 480 px (thumb=1): es la que se repite 500 veces por set y - // ahoga la cuadrícula. El visor grande (thumb=xl) y la anotación sobre lienzo NO se tocan — - // cargar la imagen del lienzo por otro esquema lo «ensuciaría» y rompería «guardar el dibujo». - try { - function porCajon(src) { - if (!src || src.lastIndexOf('lsthumb:', 0) === 0) return src; // ya reescrita - var u = abs(src); - if (!u || u.origin !== ORIGIN) return src; // solo lo del propio servidor - if (!/\/api\/files-asset\//.test(u.pathname) || u.searchParams.get('thumb') !== '1') return src; - return 'lsthumb://c/?u=' + encodeURIComponent(u.href); - } - var proto = HTMLImageElement.prototype; - // 1) La propiedad .src (lo que usa React al asignar la imagen). - var d = Object.getOwnPropertyDescriptor(proto, 'src'); - if (d && d.get && d.set) { - Object.defineProperty(proto, 'src', { - configurable: true, enumerable: d.enumerable, - get: function () { return d.get.call(this); }, - set: function (v) { d.set.call(this, porCajon(String(v))); }, - }); - } - // 2) setAttribute('src', …), que no pasa por el setter de la propiedad. Se parchea SOLO en - // (prototipo de HTMLImageElement), no en todos los elementos. - var setA = proto.setAttribute; - proto.setAttribute = function (name, value) { - if (arguments.length >= 2 && String(name).toLowerCase() === 'src') { - return setA.call(this, name, porCajon(String(value))); - } - return setA.apply(this, arguments); - }; - // 3) Las que ya vienen en el HTML del servidor (SSR) o llegan luego. porCajon es idempotente - // (una ya reescrita se devuelve igual), así que barrer no entra en bucle con el observer. - function barrer(raiz) { - if (!raiz || !raiz.querySelectorAll) return; - var imgs = raiz.querySelectorAll('img[src]'); - for (var i = 0; i < imgs.length; i++) { - var cur = imgs[i].getAttribute('src'); - var nue = porCajon(cur); - if (nue !== cur) imgs[i].setAttribute('src', nue); - } - } - if (window.MutationObserver) { - new MutationObserver(function (muts) { - for (var i = 0; i < muts.length; i++) { - var m = muts[i]; - if (m.type === 'attributes' && m.target && m.target.tagName === 'IMG') { - var cur = m.target.getAttribute('src'), nue = porCajon(cur); - if (nue !== cur) m.target.setAttribute('src', nue); - } - for (var j = 0; j < m.addedNodes.length; j++) { - var n = m.addedNodes[j]; - if (!n || n.nodeType !== 1) continue; - if (n.tagName === 'IMG') { var c = n.getAttribute('src'), x = porCajon(c); if (x !== c) n.setAttribute('src', x); } - else barrer(n); - } - } - }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['src'] }); - } - if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function () { barrer(document); }); - else barrer(document); - } catch (e) { /* si algo falla, las imágenes cargan directo del servidor como siempre */ } + // Las imagenes conservan su URL HTTPS y la sesion del webview. El descargador + // lsthumb no comparte cookies: falla con miniaturas sin token y omite la + // revalidacion de permisos al leer del disco. La cache HTTP respeta los ETag + // y Cache-Control del servidor sin modificar src, srcset ni la hidratacion. // ── ¿La página se VE oscura? ── // Señales, de la más directa a la más física: la clase `dark` (next-themes) y, si no @@ -388,7 +315,6 @@ const INIT_JS_TPL: &str = r#" history[k] = function () { var r = o.apply(this, arguments); setTimeout(report, 0); return r; }; }); addEventListener('popstate', function () { setTimeout(report, 0); }); - if (esGoogle) return; // en Google solo miramos el titulo // Pulsar la luna cambia clases/estilos sin navegar: se vigilan y (cuando // existan — a document-start aún no hay), y tras la carga se re-reporta un par de veces // por si el tema aterriza tarde con la hidratación. @@ -411,9 +337,6 @@ const INIT_JS_TPL: &str = r#" if (!u) return; if (isExt(u)) { e.preventDefault(); openExternal(u.href); return; } var mod = e.metaKey || e.ctrlKey; - // Enlace a Google (docs, drive, sheets, accounts): SIEMPRE como pestana propia - // aunque no lleve target=_blank, para que la sesion viva dentro de la app. - if (isGoogleUrl(u)) { e.preventDefault(); newTab(u.href); return; } if (isApp(u) && (mod || a.target === '_blank')) { e.preventDefault(); newTab(u.href); } }, true); @@ -612,9 +535,16 @@ fn create_tab(app: &AppHandle, url: Option, activate: boo let init = INIT_JS_TPL.replace("__LABEL__", &label).replace("__ORIGIN__", SERVER_URL); let app_zoom = app.clone(); + let app_navigation = app.clone(); let builder = WebviewBuilder::new(&label, WebviewUrl::External(parsed)) - .user_agent(UA_ESCRITORIO) .initialization_script(&init) + .on_navigation(move |url| { + if url.scheme() == "https" && url.host_str().map(is_google_host).unwrap_or(false) { + let _ = app_navigation.opener().open_url(url.as_str(), None::<&str>); + return false; + } + true + }) // Deja pasar las descargas (entregables, exportaciones) Y avisa al terminar. // El aviso no es un adorno: en WebView2 la interfaz de descargas queda SIEMPRE // apagada. wry trae un manejador de descargas por defecto (wry-0.55.1/src/lib.rs:830), diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 04f5a5f..69267eb 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Labstream OS", - "version": "1.14.0", + "version": "1.14.1", "identifier": "co.labstream.os", "build": { "frontendDist": "../dist" diff --git a/tests/images.browser.cjs b/tests/images.browser.cjs new file mode 100644 index 0000000..0a57c09 --- /dev/null +++ b/tests/images.browser.cjs @@ -0,0 +1,84 @@ +// Run with Playwright available: node tests/images.browser.cjs +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const http = require('node:http'); +const { chromium, webkit } = require('playwright'); + +const root = path.join(__dirname, '..'); +const source = fs.readFileSync(path.join(root, 'src-tauri/src/lib.rs'), 'utf8'); +const template = source.match(/const INIT_JS_TPL: &str = r#"([\s\S]*?)"#;/)[1]; +const png = fs.readFileSync(path.join(root, 'app-icon.png')); +const urls = [ + '/api/files-asset/session?thumb=1', + '/api/files-asset/signed?thumb=1&t=expired.sig', + '/api/files-asset/poster?poster=1', + '/api/files-asset/large?thumb=xl', + '/api/files-asset/original', +]; +const counts = { authenticated: 0, revalidated: 0, unauthorized: 0 }; +const server = http.createServer((req, res) => { + if (req.url.startsWith('/api/files-asset/')) { + if (req.headers.cookie !== 'test-session=ok') { + counts.unauthorized++; + res.writeHead(401, { 'Cache-Control': 'no-store' }).end(); + return; + } + counts.authenticated++; + const headers = { 'Content-Type': 'image/png', 'Cache-Control': 'private, no-cache', ETag: '"cover-1"' }; + if (req.headers['if-none-match'] === '"cover-1"') { + counts.revalidated++; + res.writeHead(304, headers).end(); + } else res.writeHead(200, headers).end(png); + return; + } + res.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-store' }); + res.end(`Portadas: prueba local + ${urls.map((url, i) => ``).join('')} + `); +}); + +(async () => { + let browser; + try { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const origin = `http://127.0.0.1:${server.address().port}`; + browser = await (process.env.TEST_BROWSER === 'webkit' ? webkit : chromium).launch({ headless: true }); + const context = await browser.newContext(); + await context.addCookies([{ name: 'test-session', value: 'ok', url: origin, httpOnly: true }]); + await context.addInitScript(`window.__TAURI__ = { event: { emit() {} } };`); + await context.addInitScript(template.replaceAll('__ORIGIN__', origin).replaceAll('__LABEL__', 'tab-test')); + const page = await context.newPage(); + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + await page.goto(origin); + await page.waitForFunction(() => [...document.images].every((image) => image.complete && image.naturalWidth > 0)); + assert.deepEqual(await page.locator('img').evaluateAll((images) => images.map((image) => image.getAttribute('src'))), urls); + // Cached images must still be readable by the annotation canvas. + assert.equal(await page.evaluate(() => { + const canvas = document.querySelector('canvas'); + canvas.getContext('2d').drawImage(document.images[0], 0, 0, 16, 16); + return canvas.toDataURL().startsWith('data:image/png;'); + }), true); + await page.reload(); + await page.waitForFunction(() => [...document.images].every((image) => image.complete && image.naturalWidth > 0)); + assert.ok(counts.revalidated >= urls.length, 'HTTP cache revalidation expected on reload'); + await page.evaluate(() => { + const image = new Image(); + image.id = 'dynamic'; + image.setAttribute('src', '/api/files-asset/dynamic?thumb=1'); + document.body.appendChild(image); + }); + await page.waitForFunction(() => document.getElementById('dynamic').naturalWidth > 0); + // A native downloader without the webview session reproduces the 401. + assert.equal((await fetch(origin + urls[0])).status, 401); + await context.clearCookies(); + await page.reload(); + assert.equal(await page.locator('img').evaluateAll((images) => images.filter((image) => image.naturalWidth > 0).length), 0); + assert.deepEqual(errors, []); + console.log(JSON.stringify({ ok: true, covers: urls.length, dynamic: true, canvas: true, ...counts })); + } finally { + if (browser) await browser.close(); + await new Promise((resolve) => server.close(resolve)); + } +})().catch((error) => { console.error(error); process.exitCode = 1; }); diff --git a/tests/images.test.cjs b/tests/images.test.cjs new file mode 100644 index 0000000..58da3aa --- /dev/null +++ b/tests/images.test.cjs @@ -0,0 +1,102 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const source = fs.readFileSync(path.join(__dirname, '../src-tauri/src/lib.rs'), 'utf8'); +const template = source.match(/const INIT_JS_TPL: &str = r#"([\s\S]*?)"#;/)[1]; +const origin = 'https://os.labstreamsas.com'; + +function shell({ frame = false, host = origin, images = [] } = {}) { + class Image { + constructor(src = '') { this.attrs = { src }; } + get src() { return this.attrs.src; } + set src(value) { this.attrs.src = value; } + getAttribute(name) { return this.attrs[name]; } + setAttribute(name, value) { this.attrs[name] = value; } + } + const setter = Object.getOwnPropertyDescriptor(Image.prototype, 'src').set; + const setAttribute = Image.prototype.setAttribute; + const emitted = []; + const opened = []; + const listeners = {}; + const nodes = images.map((src) => new Image(src)); + const root = { classList: { contains: () => false } }; + const win = { + __TAURI__: { event: { emit: (...args) => emitted.push(args) }, core: { invoke: (...args) => opened.push(args) } }, + open: () => 'original', + }; + win.self = win; + win.top = frame ? {} : win; + const sandbox = { + window: win, URL, HTMLImageElement: Image, + location: new URL(host + '/proyectos/ejemplo'), + document: { + readyState: 'complete', title: 'Entregables', documentElement: root, body: root, + querySelector: () => null, querySelectorAll: () => nodes, + addEventListener: (name, callback) => { listeners[name] = callback; }, + }, + history: {}, navigator: { platform: 'MacIntel' }, + getComputedStyle: () => ({ backgroundColor: 'rgb(255, 255, 255)' }), + addEventListener: (name, callback) => { listeners[name] = callback; }, + setTimeout: () => 0, + }; + vm.runInNewContext(template.replaceAll('__ORIGIN__', origin).replaceAll('__LABEL__', 'tab-1'), sandbox); + return { Image, nodes, setter, setAttribute, win, emitted, opened, listeners }; +} + +test('does not replace native image setters or rewrite server-rendered covers', () => { + const urls = ['/api/files-asset/cover?thumb=1', '/api/files-asset/cover?t=123.sig&thumb=1']; + const state = shell({ images: urls }); + assert.equal(Object.getOwnPropertyDescriptor(state.Image.prototype, 'src').set, state.setter); + assert.equal(state.Image.prototype.setAttribute, state.setAttribute); + assert.deepEqual(state.nodes.map((image) => image.src), urls); +}); + +for (const url of [ + '/api/files-asset/cover?thumb=1', + '/api/files-asset/cover?t=expired.sig&thumb=1&v=abc', + '/api/files-asset/cover?thumb=xl', + '/api/files-asset/video?poster=1', + '/api/files-asset/original', + 'https://other.example/image.webp', +]) { + test(`keeps authenticated browser transport for ${url}`, () => { + const { Image } = shell(); + const image = new Image(); + image.src = url; + assert.equal(image.src, url); + image.setAttribute('src', url + '&r=1'); + assert.equal(image.src, url + '&r=1'); + }); +} + +test('tab opening and title reports still work', () => { + const { win, emitted } = shell(); + win.open('/revisiones/123', '_blank'); + assert.equal(emitted[0][0], 'ls-title'); + assert.equal(emitted[0][1].title, 'Entregables'); + assert.equal(emitted[1][0], 'ls-new-tab'); + assert.equal(emitted[1][1].url, origin + '/revisiones/123'); +}); + +test('does not interfere with embedded editors or unrelated origins', () => { + for (const options of [{ frame: true }, { host: 'https://docs.example.com' }, { host: 'https://docs.google.com' }]) { + const { win, emitted } = shell(options); + assert.equal(win.__lsShell, undefined); + assert.equal(win.open(), 'original'); + assert.equal(emitted.length, 0); + } +}); + +test('Google editors and login use the system browser, never an embedded tab', () => { + for (const host of ['docs', 'sheets', 'drive', 'accounts']) { + const { win, emitted, opened } = shell(); + const url = `https://${host}.google.com/example`; + win.open(url, '_blank'); + assert.equal(emitted.filter(([name]) => name === 'ls-new-tab').length, 0); + assert.equal(opened[0][0], 'plugin:opener|open_url'); + assert.equal(opened[0][1].url, url); + } +});