reliability: verify recovery, retain menus during updates, and report delivery state
Makes repeated setup idempotent for the same owner, adds reproducible Chromium/WebKit gates and CI, and logs content-free storage diagnostics for the hosted restart failure.
Co-Authored-By: Codex <noreply@openai.com>
18 files changed,
+217
−13
.github/workflows/infinity.yml+23 −0.gitignore+5 −0apple/README.md+16 −0cmd/infinity-home/main.go+2 −0cmd/infinity/main.go+2 −0docs/infinity/status.md+21 −0internal/infinity/app.go+18 −4internal/infinity/static/chat.js+3 −1internal/infinity/static/client.js+10 −2internal/infinity/static/crypto.js+1 −1internal/infinitydiag/storage.go+29 −0test/infinity/browser.mjs+12 −3test/infinity/live.mjs+1 −1test/infinity/package-lock.json+42 −0test/infinity/package.json+8 −0test/infinity/protocol.test.mjs+5 −0tools/infinity/deploy+18 −0tools/infinity/render-icons.mjs+1 −1
diff --git a/.github/workflows/infinity.yml b/.github/workflows/infinity.yml| new file mode 100644 |
| index 0000000..c97fdaf |
| --- /dev/null |
| +++ b/.github/workflows/infinity.yml |
| @@ -0,0 +1,23 @@ |
| +# π€ The personal messenger has an explicit gate beside inherited Eleven checks. |
| +name: Infinity |
| +on: |
| + pull_request: |
| + paths: ['cmd/**', 'internal/infinity/**', 'internal/infinityhome/**', 'test/infinity/**', 'tools/infinity/**', 'go.mod', 'go.sum', '.github/workflows/infinity.yml'] |
| + workflow_dispatch: |
| +permissions: |
| + contents: read |
| +jobs: |
| + chat: |
| + runs-on: ubuntu-latest |
| + steps: |
| + - uses: actions/checkout@v4 |
| + - uses: actions/setup-go@v5 |
| + with: |
| + go-version: '1.25.13' |
| + - uses: actions/setup-node@v4 |
| + with: |
| + node-version: '24' |
| + - run: npm ci --prefix test/infinity |
| + - run: npx --prefix test/infinity playwright install --with-deps chromium webkit |
| + - run: mkdir -p /private/tmp/infinity-browser |
| + - run: tools/infinity/check all |
diff --git a/.gitignore b/.gitignore| index 657f421..29578a2 100644 |
| --- a/.gitignore |
| +++ b/.gitignore |
| @@ -41,3 +41,8 @@ ios/LChatCore/.swiftpm/ |
| home/home |
| lchat-linux-amd64 |
| home-linux-amd64 |
| + |
| +# β development dependencies and isolated worktrees |
| +node_modules/ |
| +.worktrees/ |
| +.build/ |
diff --git a/apple/README.md b/apple/README.md| new file mode 100644 |
| index 0000000..ca11a87 |
| --- /dev/null |
| +++ b/apple/README.md |
| @@ -0,0 +1,16 @@ |
| +# π€ β for iPhone, iPad and Mac |
| + |
| +The initial Apple apps use the same WebKit chat client as the web version, in a SwiftUI shell. Encryption runs in WebKit; origin-isolated storage belongs to these apps, with no Eleven shared keychain group or account container. |
| + |
| +Build from the repository root: |
| + |
| +``` |
| +tools/infinity/check mac |
| +tools/infinity/check ios |
| +``` |
| + |
| +Requires Xcode and xcodegen. Both targets have been compiled without signing against the installed Mac and iOS Simulator SDKs. A device/App Store build needs a signing team and provisioning profiles for `chat.ocho.infinity` and `chat.ocho.infinity.mac`. |
| + |
| +The release home is `https://ocho.chat`. For a Debug build, `INFINITY_HOME` may point to the hosted review origin. A recovery export opens the system save/share UI; restoring uses the file picker in the shared client. `ocho://open?url=<encoded HTTPS URL>` opens a link. Universal links and native background notifications are not yet configured; these are explicit release follow-ups, not simulated features. |
| + |
| +The logo comes from the Myriad Pro infinity glyph, outlined once by `tools/infinity/make-mark.swift` and rendered at required sizes by `tools/infinity/render-icons.mjs`. The font file is not distributed. |
diff --git a/cmd/infinity-home/main.go b/cmd/infinity-home/main.go| index d72e1cf..91d5d66 100644 |
| --- a/cmd/infinity-home/main.go |
| +++ b/cmd/infinity-home/main.go |
| @@ -5,6 +5,7 @@ import ( |
| "amadan.net/rastrillo/rastrillo" |
| "amadan.net/rastrillo/rastrillo/db" |
| "encoding/base64" |
| + "lchat/internal/infinitydiag" |
| "lchat/internal/infinityhome" |
| "log/slog" |
| "os" |
| @@ -24,6 +25,7 @@ func run() error { |
| } |
| d, e := db.Open(opts.DBPath, log) |
| if e != nil { |
| + infinitydiag.Storage(opts.DBPath) |
| return e |
| } |
| defer d.Close() |
diff --git a/cmd/infinity/main.go b/cmd/infinity/main.go| index 77cdf85..1c9d456 100644 |
| --- a/cmd/infinity/main.go |
| +++ b/cmd/infinity/main.go |
| @@ -6,6 +6,7 @@ import ( |
| "amadan.net/rastrillo/rastrillo/db" |
| "encoding/base64" |
| "lchat/internal/infinity" |
| + "lchat/internal/infinitydiag" |
| "log/slog" |
| "os" |
| ) |
| @@ -24,6 +25,7 @@ func run() error { |
| } |
| d, e := db.Open(opts.DBPath, log) |
| if e != nil { |
| + infinitydiag.Storage(opts.DBPath) |
| return e |
| } |
| defer d.Close() |
diff --git a/docs/infinity/status.md b/docs/infinity/status.md| new file mode 100644 |
| index 0000000..2c7fd6e |
| --- /dev/null |
| +++ b/docs/infinity/status.md |
| @@ -0,0 +1,21 @@ |
| +# π€ β status β 2026-09-05 |
| + |
| +## Implemented |
| + |
| +Carlos personal-instance runtime; separate registration binary with an app-scoped instance credential and public-key-verified setup tickets; signed, encrypted, consent-gated DMs; group creation/acceptance/removal; flat mobile/desktop chat; ciphertext history in every participant's instance; durable outbox, retries and delivery status; encrypted account-state compare-and-swap; password-encrypted recovery and another-device restore; Myriad Pro infinity outline; isolated iOS/macOS WebKit apps. |
| + |
| +## Validation executed |
| + |
| +All inherited Go and Node tests passed. New Go protocol/storage tests, Node crypto/projection tests and three-instance Chromium/WebKit drives passed (including recovery on an empty browser context). Both Apple targets compiled with signing disabled. govulncheck reported no vulnerabilities after Go 1.25.13 replaced the inherited 1.25.12 toolchain. The browser screenshot helper injects inline CSS in WebKit; visual captures use Chromium, while the full WebKit interaction drive runs under the unchanged strict CSP without screenshot injection. |
| + |
| +## Hosted verification still in progress |
| + |
| +Account `ocho` / `bdy`; apps `infinity` and `infinity-home`; channel `canary/infinity`. Review endpoints are `https://infinity-test.bdy.oncarlos.com` and `https://infinity-home-test.bdy.oncarlos.com`. Initial deployment served verified build `3f8e1eab`. Registration created a distinct instance, but redirected before route adoption; the client now waits on the home origin until the process is ready. A subsequent new instance served `f38aaac1`. Existing canaries remained on the older build until restart; restart then exposed `SQLITE_IOERR_SHMSIZE (4874)` at database open. No database has been deleted or reset. Filesystem metadata diagnostics are being added to identify the underlying platform cause. |
| + |
| +`ocho.chat` is attached to the registration canary. DNS is pending: apex A records at 99.81.104.219, 54.252.186.195, 16.60.78.128 and 44.210.206.26, TTL 300. Provider information has been requested. |
| + |
| +## Explicit limits |
| + |
| +This is an early text-chat implementation, not an independently audited security product. Recipient-key encryption has no double-ratchet forward secrecy. Web clients trust code served by their origin. Routing metadata, timing, sizes, public keys and outbound delivery capabilities are server-visible. Recovery currently clones the identity across devices; independent device revocation and passkey-based recovery remain future work. Apple apps share the tested web UI; universal links, background push and App Store signing are not configured. Attachments, voice and video are not implemented. |
| + |
| +Source work is on `infinity-v1` in the isolated `.worktrees/infinity` checkout. The root checkout preserves the Eleven fork baseline; no main-branch merge has been performed. No remote fork repository has been selected or pushed. This status is updated as verification progresses; it is not a completion claim. |
diff --git a/internal/infinity/app.go b/internal/infinity/app.go| index 2096668..f5dffc5 100644 |
| --- a/internal/infinity/app.go |
| +++ b/internal/infinity/app.go |
| @@ -185,16 +185,30 @@ func (a *App) setup(w http.ResponseWriter, r *http.Request) { |
| fail(w, 400, "Invalid account keys.") |
| return |
| } |
| - _, e := a.db.ExecContext(r.Context(), "INSERT INTO identity(singleton,auth_hash,enc,signing) VALUES(1,?,?,?)", hash(p.Auth), p.Enc, p.Signing) |
| + res, e := a.db.ExecContext(r.Context(), "INSERT OR IGNORE INTO identity(singleton,auth_hash,enc,signing) VALUES(1,?,?,?)", hash(p.Auth), p.Enc, p.Signing) |
| if e != nil { |
| - var n int |
| - if a.db.QueryRow("SELECT count(*) FROM identity").Scan(&n) == nil && n > 0 { |
| + dbError(w, e) |
| + return |
| + } |
| + n, e := res.RowsAffected() |
| + if e != nil { |
| + dbError(w, e) |
| + return |
| + } |
| + if n == 0 { |
| + var auth, enc, sign string |
| + if e = a.db.QueryRowContext(r.Context(), "SELECT auth_hash,enc,signing FROM identity WHERE singleton=1").Scan(&auth, &enc, &sign); e != nil { |
| + dbError(w, e) |
| + return |
| + } |
| + if subtle.ConstantTimeCompare([]byte(auth), []byte(hash(p.Auth))) != 1 || enc != p.Enc || sign != p.Signing { |
| fail(w, 409, "This account is already set up.") |
| return |
| } |
| - dbError(w, e) |
| + reply(w, 200, map[string]bool{"ok": true, "existing": true}) |
| return |
| } |
| + |
| reply(w, 201, map[string]bool{"ok": true}) |
| } |
| func validSecret(s string) bool { |
diff --git a/internal/infinity/static/chat.js b/internal/infinity/static/chat.js| index 3036564..2696991 100644 |
| --- a/internal/infinity/static/chat.js |
| +++ b/internal/infinity/static/chat.js |
| @@ -21,6 +21,7 @@ export class Chat { |
| setStatus() { const node = this.root.querySelector('.connection'); if (node) node.textContent = this.error; } |
| render() { |
| const focused = document.activeElement?.id; |
| + const menuWasOpen = this.root.querySelector('.account-menu')?.open; |
| const selected = this.client.view.threads.find(t => t.id === this.selected); |
| const shell = el('div', { class: 'shell' + (selected ? ' conversation-open' : '') }); |
| const header = el('header', { class: 'sidebar-header' }, el('div', { class: 'brand' }, mark(), el('span', {}, 'Messages')), button('Invite', () => share(this.client), { class: 'small primary' })); |
| @@ -29,6 +30,7 @@ export class Chat { |
| this.list = el('nav', { class: 'thread-list', 'aria-label': 'Conversations' }); |
| const menu = el('details', { class: 'account-menu' }, el('summary', {}, avatar(this.client.state.name), el('span', {}, this.client.state.name), el('span', { class: 'muted' }, 'β')), |
| el('div', { class: 'menu-items' }, button('Connect with a link', () => connectDialog(this.client, id => this.select(id))), button('New group', () => newGroup(this.client, id => this.select(id))), button('Recovery & another device', () => recovery(this.client)), button('About privacy', () => this.privacy()))); |
| + menu.open = Boolean(menuWasOpen); |
| const aside = el('aside', { class: 'sidebar' }, header, el('div', { class: 'search-wrap' }, search), this.list, el('p', { class: 'connection', role: 'status' }, this.error), menu); |
| shell.append(aside, selected ? this.conversation(selected) : this.empty()); this.root.replaceChildren(shell); this.renderList(); |
| if (focused === 'composer') this.root.querySelector('#composer')?.focus(); |
| @@ -58,7 +60,7 @@ export class Chat { |
| for (const m of t.messages) { |
| const day = new Date(m.at).toLocaleDateString([], { weekday: 'long', month: 'short', day: 'numeric' }); |
| if (day !== date) { log.append(el('div', { class: 'date-label' }, day)); date = day; } |
| - log.append(el('article', { class: 'message ' + (m.mine ? 'mine' : 'theirs') }, t.group && !m.mine && el('strong', { class: 'sender-name' }, m.name), el('p', {}, m.text), el('time', { datetime: new Date(m.at).toISOString() }, new Date(m.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })))); |
| + log.append(el('article', { class: 'message ' + (m.mine ? 'mine' : 'theirs') }, t.group && !m.mine && el('strong', { class: 'sender-name' }, m.name), el('p', {}, m.text), el('time', { datetime: new Date(m.at).toISOString() }, new Date(m.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), m.delivery ? ' Β· ' + m.delivery : ''))); |
| } |
| const note = el('div', { class: 'composer-note', role: 'status' }); |
| const input = el('textarea', { id: 'composer', rows: 1, maxlength: 8000, placeholder: 'Message', 'aria-label': 'Message', disabled: t.pending || t.left }); input.value = this.drafts.get(t.id) || ''; |
diff --git a/internal/infinity/static/client.js b/internal/infinity/static/client.js| index 2f751fe..1c18fbe 100644 |
| --- a/internal/infinity/static/client.js |
| +++ b/internal/infinity/static/client.js |
| @@ -10,7 +10,8 @@ export class Client { |
| api(path, options = {}) { return request(this.id.origin, path, { auth: this.id.auth, ...options }); } |
| async setup(claim) { |
| try { |
| - await request(this.id.origin, '/api/setup', { method: 'POST', body: { auth: this.id.auth, enc: this.id.enc, signing: this.id.signing, claim } }); |
| + const result = await request(this.id.origin, '/api/setup', { method: 'POST', body: { auth: this.id.auth, enc: this.id.enc, signing: this.id.signing, claim } }); |
| + if (result.existing) await this.load(); |
| } catch (error) { |
| if (error.status !== 409) throw error; |
| // A lost setup reply must not strand the same identity. The owner bearer |
| @@ -43,7 +44,14 @@ export class Client { |
| } |
| this.statuses = await this.api('/api/delivery'); this.rebuild(); |
| } |
| - rebuild() { this.view = project(this.id, this.state, this.events); } |
| + rebuild() { |
| + this.view = project(this.id, this.state, this.events); |
| + for (const thread of this.view.threads) for (const message of thread.messages) { |
| + if (!message.mine) continue; |
| + const receipts = this.statuses.filter(s => s.id === message.id && s.destination !== this.id.origin); |
| + if (receipts.length) message.delivery = receipts.every(s => s.delivered > 0) ? 'Sent' : receipts.some(s => s.attempts > 0) ? 'Not delivered Β· retrying' : 'Sendingβ¦'; |
| + } |
| + } |
| async emit(people, payload) { |
| const unique = [...new Map(people.map(p => [p.signing, p])).values()]; |
| const items = await Promise.all(unique.map(async person => ({ destination: peerOrigin(person.origin), capability: person.cap, envelope: await seal(this.id, person, payload) }))); |
diff --git a/internal/infinity/static/crypto.js b/internal/infinity/static/crypto.js| index 9d87cf9..09867ea 100644 |
| --- a/internal/infinity/static/crypto.js |
| +++ b/internal/infinity/static/crypto.js |
| @@ -28,7 +28,7 @@ export async function seal(identity, recipient, payload) { |
| const pair = await subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']); |
| const nonce = crypto.getRandomValues(new Uint8Array(12)); |
| const shared = await subtle.deriveBits({ name: 'ECDH', public: await importPublic(recipient.enc, 'ECDH') }, pair.privateKey, 256); |
| - const e = { id: random(), to: recipient.enc, sender: identity.signing, ephemeral: b64(await subtle.exportKey('raw', pair.publicKey)), nonce: b64(nonce) }; |
| + const e = { id: payload.kind === 'message' ? payload.id : random(), to: recipient.enc, sender: identity.signing, ephemeral: b64(await subtle.exportKey('raw', pair.publicKey)), nonce: b64(nonce) }; |
| e.box = b64(await subtle.encrypt({ name: 'AES-GCM', iv: nonce, additionalData: te.encode(header(e)) }, await aes(shared, nonce), te.encode(JSON.stringify(payload)))); |
| const sk = await subtle.importKey('jwk', identity.privateSign, { name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign']); |
| e.signature = b64(await subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, sk, signed(e))); |
diff --git a/internal/infinitydiag/storage.go b/internal/infinitydiag/storage.go| new file mode 100644 |
| index 0000000..ee4f307 |
| --- /dev/null |
| +++ b/internal/infinitydiag/storage.go |
| @@ -0,0 +1,29 @@ |
| +// π€ Report filesystem metadata only when SQLite cannot open; never read content. |
| +package infinitydiag |
| + |
| +import ( |
| + "log/slog" |
| + "os" |
| + "path/filepath" |
| +) |
| + |
| +func Storage(path string) { |
| + for _, p := range []string{filepath.Dir(path), path, path + "-wal", path + "-shm"} { |
| + st, e := os.Stat(p) |
| + if e != nil { |
| + slog.Error("infinity storage diagnostic", "path", p, "stat_error", e) |
| + continue |
| + } |
| + attrs := []any{"path", p, "mode", st.Mode().String(), "size", st.Size(), "process_uid", os.Getuid()} |
| + if !st.IsDir() { |
| + f, e := os.OpenFile(p, os.O_RDWR, 0) |
| + if e != nil { |
| + attrs = append(attrs, "open_readwrite_error", e) |
| + } else { |
| + f.Close() |
| + attrs = append(attrs, "open_readwrite", true) |
| + } |
| + } |
| + slog.Error("infinity storage diagnostic", attrs...) |
| + } |
| +} |
diff --git a/test/infinity/browser.mjs b/test/infinity/browser.mjs| index 3773538..03c2275 100644 |
| --- a/test/infinity/browser.mjs |
| +++ b/test/infinity/browser.mjs |
| @@ -1,6 +1,6 @@ |
| // π€ Real Chromium/WebKit drive against three independent personal processes. |
| import { createRequire } from 'node:module';import { spawn } from 'node:child_process';import { mkdtemp,readFile } from 'node:fs/promises';import { tmpdir } from 'node:os';import path from 'node:path';import assert from 'node:assert/strict';import { randomBytes } from 'node:crypto'; |
| -const require=createRequire(process.env.INFINITY_PLAYWRIGHT_PATH || '/private/tmp/infinity-browser/package.json'); |
| +const require=createRequire(process.env.INFINITY_PLAYWRIGHT_PATH || new URL('./package.json', import.meta.url)); |
| const {chromium,webkit}=require('playwright'); |
| const engine=process.env.INFINITY_BROWSER==='webkit'?webkit:chromium; |
| const ports=[18741,18742,18743],origins=ports.map(p=>'http://127.0.0.1:'+p),claim=randomBytes(32).toString('base64url'); |
| @@ -29,7 +29,7 @@ try{ |
| await a.getByRole('log').getByText('Hello Alice β private-canary-text',{exact:true}).waitFor(); |
| await a.getByRole('textbox',{name:'Message',exact:true}).fill('Hello Bob, it works both ways.');await a.getByRole('button',{name:'Send message',exact:true}).click(); |
| await b.getByRole('log').getByText('Hello Bob, it works both ways.',{exact:true}).waitFor(); |
| - await connect(c,await invite(a));await a.getByRole('button',{name:'Accept',exact:true}).click(); |
| + await connect(c,await invite(a));await a.getByRole('button',{name:'Accept',exact:true}).click();await a.getByRole('heading',{name:'Carol',exact:true}).waitFor(); |
| await a.locator('.account-menu summary').click();await a.getByRole('button',{name:'New group',exact:true}).click();await a.getByLabel('Group name').fill('The weekend');await a.getByLabel('Bob',{exact:true}).check();await a.getByLabel('Carol',{exact:true}).check();await a.getByRole('button',{name:'Create group',exact:true}).click(); |
| await b.getByRole('button',{name:'Accept',exact:true}).click();await b.getByRole('heading',{name:'The weekend',exact:true}).waitFor();await c.getByRole('button',{name:'Accept',exact:true}).click();await c.getByRole('heading',{name:'The weekend',exact:true}).waitFor(); |
| await c.getByRole('textbox',{name:'Message',exact:true}).fill('Anyone for a walk?');await c.getByRole('button',{name:'Send message',exact:true}).click(); |
| @@ -40,12 +40,21 @@ try{ |
| await a.getByRole('textbox',{name:'Message',exact:true}).fill('This is only for Alice and Bob.');await a.getByRole('button',{name:'Send message',exact:true}).click();await b.getByRole('log').getByText('This is only for Alice and Bob.',{exact:true}).waitFor(); |
| assert.equal(await c.getByRole('log').getByText('This is only for Alice and Bob.',{exact:true}).count(),0); |
| await a.reload();await a.locator('.thread-row').filter({hasText:'The weekend'}).click();await a.getByRole('log').getByText('Anyone for a walk?',{exact:true}).waitFor(); |
| + // Repeating setup after a lost reply preserves the same identity/history. |
| + await a.evaluate(async claim => { const { Client } = await import('/static/client.js'); const id=JSON.parse(localStorage.getItem('infinity.identity.v1')); await new Client(id).setup(claim); },claim); |
| + // Recover through the real file/password flow in a fourth, empty browser context. |
| + await a.locator('.account-menu summary').click();await a.getByRole('button',{name:'Recovery & another device',exact:true}).click(); |
| + await a.getByLabel('Recovery password',{exact:true}).fill('our test recovery password');await a.getByLabel('Repeat password',{exact:true}).fill('our test recovery password'); |
| + const downloaded=a.waitForEvent('download');await a.getByRole('button',{name:'Save recovery file',exact:true}).click();const download=await downloaded;const recoveryFile=path.join(dir,'recovery.json');await download.saveAs(recoveryFile);await a.getByRole('button',{name:'Close',exact:true}).click(); |
| + const recovered=await (await browser.newContext()).newPage();await recovered.goto(origins[0]);await recovered.getByRole('button',{name:'I have a recovery file',exact:true}).click(); |
| + await recovered.getByLabel('Recovery file',{exact:true}).setInputFiles(recoveryFile);await recovered.getByLabel('Recovery password',{exact:true}).fill('our test recovery password');await recovered.getByRole('button',{name:'Open your account',exact:true}).click(); |
| + await recovered.locator('.thread-row').filter({hasText:'The weekend'}).click();await recovered.getByRole('log').getByText('Anyone for a walk?',{exact:true}).waitFor();await recovered.close(); |
| await b.setViewportSize({width:390,height:844});if(engine!==webkit)await b.screenshot({caret:'initial',path:path.join(dir,'mobile.png'),fullPage:true}); |
| assert(await b.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),'mobile overflow'); |
| // Raw database + WAL check includes the actual browser-generated plaintext marker. |
| for(let i=0;i<3;i++)for(const suffix of ['', '-wal']){let bytes;try{bytes=await readFile(path.join(dir,i+'.db'+suffix));}catch(e){if(e.code==='ENOENT')continue;throw e;}for(const text of ['private-canary-text','Anyone for a walk?','This is only for Alice and Bob.'])assert(!bytes.includes(Buffer.from(text)),'plaintext persisted in DB/WAL');} |
| for(const page of pages){const text=await page.locator('body').innerText();assert(!/\[object Object\]|\bundefined\b|\bNaN\b/.test(text),'rendered JS junk');} |
| - assert.deepEqual(errors,[]);console.log(JSON.stringify({browser:process.env.INFINITY_BROWSER||'chromium',result:'passed',screenshots:engine===webkit?null:dir,scenarios:['three isolated instances','invite + consent','bidirectional DMs','group + acceptance','member removal','reload history','mobile layout','SQLite/WAL plaintext absence','no browser errors']})); |
| + assert.deepEqual(errors,[]);console.log(JSON.stringify({browser:process.env.INFINITY_BROWSER||'chromium',result:'passed',screenshots:engine===webkit?null:dir,scenarios:['three isolated instances','invite + consent','bidirectional DMs','group + acceptance','member removal','reload history','idempotent setup','recovery on another device','mobile layout','SQLite/WAL plaintext absence','no browser errors']})); |
| }catch(error){ |
| for(let i=0;i<pages.length;i++){console.log('PAGE',i,await pages[i].locator('body').innerText());await pages[i].screenshot({caret:'initial',path:path.join(dir,'failure-'+i+'.png')});} |
| console.log('ARTIFACTS',dir);throw error; |
diff --git a/test/infinity/live.mjs b/test/infinity/live.mjs| index 9475b05..cbbf4f3 100644 |
| --- a/test/infinity/live.mjs |
| +++ b/test/infinity/live.mjs |
| @@ -1,6 +1,6 @@ |
| // π€ Deployed-canary smoke. Only fictional accounts are provisioned. |
| import {createRequire} from 'node:module';import assert from 'node:assert/strict'; |
| -const require=createRequire(process.env.INFINITY_PLAYWRIGHT_PATH||'/private/tmp/infinity-browser/package.json');const{webkit}=require('playwright'); |
| +const require=createRequire(process.env.INFINITY_PLAYWRIGHT_PATH||new URL('./package.json',import.meta.url));const{webkit}=require('playwright'); |
| const home=process.env.INFINITY_TEST_HOME||'https://infinity-home-test.bdy.oncarlos.com'; |
| const browser=await webkit.launch({headless:true});const errors=[];const pages=[]; |
| try{ |
diff --git a/test/infinity/package-lock.json b/test/infinity/package-lock.json| new file mode 100644 |
| index 0000000..ae2b9d6 |
| --- /dev/null |
| +++ b/test/infinity/package-lock.json |
| @@ -0,0 +1,42 @@ |
| +{ |
| + "name": "infinity-browser-tests", |
| + "lockfileVersion": 3, |
| + "requires": true, |
| + "packages": { |
| + "": { |
| + "name": "infinity-browser-tests", |
| + "devDependencies": { |
| + "playwright": "1.63.0" |
| + } |
| + }, |
| + "node_modules/playwright": { |
| + "version": "1.63.0", |
| + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", |
| + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", |
| + "dev": true, |
| + "license": "Apache-2.0", |
| + "dependencies": { |
| + "playwright-core": "1.63.0" |
| + }, |
| + "bin": { |
| + "playwright": "cli.js" |
| + }, |
| + "engines": { |
| + "node": ">=20" |
| + } |
| + }, |
| + "node_modules/playwright-core": { |
| + "version": "1.63.0", |
| + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", |
| + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", |
| + "dev": true, |
| + "license": "Apache-2.0", |
| + "bin": { |
| + "playwright-core": "cli.js" |
| + }, |
| + "engines": { |
| + "node": ">=20" |
| + } |
| + } |
| + } |
| +} |
diff --git a/test/infinity/package.json b/test/infinity/package.json| new file mode 100644 |
| index 0000000..7ddb3dc |
| --- /dev/null |
| +++ b/test/infinity/package.json |
| @@ -0,0 +1,8 @@ |
| +{ |
| + "name": "infinity-browser-tests", |
| + "private": true, |
| + "type": "module", |
| + "devDependencies": { |
| + "playwright": "1.63.0" |
| + } |
| +} |
diff --git a/test/infinity/protocol.test.mjs b/test/infinity/protocol.test.mjs| index cf419e5..5ee84c3 100644 |
| --- a/test/infinity/protocol.test.mjs |
| +++ b/test/infinity/protocol.test.mjs |
| @@ -29,3 +29,8 @@ test('new groups need acceptance and blocked threads never return',()=>{ |
| const s=state();s.accepted={};const v=project(a,s,[group(),message('Bob')]);assert.equal(v.requests.length,1);assert(!v.threads.some(t=>t.id==='g')); |
| s.blocked.g=true;assert.equal(project(a,s,[group()]).requests.length,0); |
| }); |
| + |
| +test('invalid timestamps cannot crash a conversation render',()=>{ |
| + const bad=[NaN,Infinity,-1,8640000000000001].map((at,i)=>({...message('Bob',1,String(i)),at})); |
| + assert.equal(project(a,state(),[group(),...bad]).threads.find(t=>t.id==='g').messages.length,0); |
| +}); |
diff --git a/tools/infinity/deploy b/tools/infinity/deploy| new file mode 100755 |
| index 0000000..5e5847d |
| --- /dev/null |
| +++ b/tools/infinity/deploy |
| @@ -0,0 +1,18 @@ |
| +#!/bin/sh |
| +# π€ Build both fleet architectures and deploy only this fork's dedicated canary. |
| +set -eu |
| +cd "$(dirname "$0")/../.." |
| +git diff --quiet |
| +git diff --cached --quiet |
| +case "${1:-}" in |
| + personal) app=infinity; package=./cmd/infinity; host=infinity-test.bdy.oncarlos.com ;; |
| + home) app=infinity-home; package=./cmd/infinity-home; host=infinity-home-test.bdy.oncarlos.com ;; |
| + *) echo 'usage: tools/infinity/deploy personal|home' >&2; exit 2 ;; |
| +esac |
| +version=$(git rev-parse --short=8 HEAD) |
| +output=".build/deploy/$app" |
| +mkdir -p "$output" |
| +for arch in arm64 amd64; do |
| + CGO_ENABLED=0 GOOS=linux GOARCH="$arch" go build -trimpath -ldflags "-s -w -X amadan.net/rastrillo/rastrillo.BuildVersion=$version" -o "$output/linux-$arch" "$package" |
| +done |
| +carlos deploy --account ocho --app "$app" --channel canary/infinity --host "$host" --version "$version" "$output/linux-arm64" "$output/linux-amd64" |
diff --git a/tools/infinity/render-icons.mjs b/tools/infinity/render-icons.mjs| index 52444cb..3d7069c 100644 |
| --- a/tools/infinity/render-icons.mjs |
| +++ b/tools/infinity/render-icons.mjs |
| @@ -1,6 +1,6 @@ |
| // π€ Render the vector source into Apple's required icon sizes; no font is shipped. |
| import {createRequire} from 'node:module';import {readFile,writeFile} from 'node:fs/promises'; |
| -const require=createRequire(process.env.INFINITY_PLAYWRIGHT_PATH||'/private/tmp/infinity-browser/package.json');const{chromium}=require('playwright'); |
| +const require=createRequire(process.env.INFINITY_PLAYWRIGHT_PATH||new URL('../../test/infinity/package.json',import.meta.url));const{chromium}=require('playwright'); |
| const browser=await chromium.launch({headless:true}); |
| try{ |
| const svg=await readFile('internal/infinity/static/mark.svg','utf8'); |