diff --git a/.changeset/editor-apply-structural-removals.md b/.changeset/editor-apply-structural-removals.md new file mode 100644 index 0000000000..fa4c4ae48f --- /dev/null +++ b/.changeset/editor-apply-structural-removals.md @@ -0,0 +1,7 @@ +--- +'@portabletext/editor': patch +--- + +fix: apply removals from structural corrections + +A non-empty string, a non-zero number, `true` or an array arriving as an entry by a collaborator's insert, `update value` or a start value (anywhere but its first root entry) is now removed and one `unset` patch is emitted, at the root, in a text block's children and in a top-level container's field. Previously it became a block holding the entry's characters or items as fields. `null`, `false`, `0` and `''` entries are not changed. diff --git a/.changeset/invalid-value-resolution-index.md b/.changeset/invalid-value-resolution-index.md new file mode 100644 index 0000000000..e3ad7369a8 --- /dev/null +++ b/.changeset/invalid-value-resolution-index.md @@ -0,0 +1,7 @@ +--- +'@portabletext/editor': patch +--- + +fix: name the entry's real index in invalid-value resolutions + +Resolutions for non-object blocks and blocks missing a `_key` now target the entry's actual position in the value. Accepting a resolution for an entry after the first removes or repairs that entry without overwriting or deleting the first block. diff --git a/.changeset/keep-block-object-children.md b/.changeset/keep-block-object-children.md new file mode 100644 index 0000000000..9ffdb6c848 --- /dev/null +++ b/.changeset/keep-block-object-children.md @@ -0,0 +1,7 @@ +--- +'@portabletext/editor': patch +--- + +fix: keep the `children` field of block objects + +A block object with its own `children` field lost it when the editor took the block in. Moving the block, or deleting it and undoing, emitted patches without the field, and inserting or pasting such a block did the same. A container registered with `arrayField: 'children'` rendered empty, and its first edit emitted a patch that replaced its blocks with one empty block. Block objects now keep every field, and these containers render their blocks and follow `value` updates. diff --git a/.changeset/remove-non-object-entries.md b/.changeset/remove-non-object-entries.md new file mode 100644 index 0000000000..3089e5c03e --- /dev/null +++ b/.changeset/remove-non-object-entries.md @@ -0,0 +1,9 @@ +--- +'@portabletext/repair': major +--- + +feat!: remove non-object array entries + +A string, number, boolean, null or array entry in an array of objects is removed with one `unset` correction. + +A correction can now contain an `unset` patch. Code that applies corrections must handle `unset` as well as `set`. `StructuralRule` gains `'non-object'`, so an exhaustive switch over correction types needs a new case. diff --git a/.github/scripts/emulator-runs.mjs b/.github/scripts/emulator-runs.mjs index 8fc4cfbd63..1cb3443d98 100644 --- a/.github/scripts/emulator-runs.mjs +++ b/.github/scripts/emulator-runs.mjs @@ -154,7 +154,31 @@ export function hostEvidence(hostLog, dmesg, timing) { Number(match[1]), ), })) - return exits.map(([, pid, epoch]) => { + return exits.map((exit) => { + const [, pid, epoch] = exit + let freezeStart = '' + let freezeSamples = 0 + const previousExit = hostLog.lastIndexOf('QEMU_GONE', exit.index - 1) + for (const line of hostLog + .slice(Math.max(0, previousExit), exit.index) + .split('\n')) { + const sample = + /^(\d+) \S+ \S+ \S+ qemu pid=(\d+) rss_kb=\d+ state=(\S+)$/.exec(line) + if (sample === null || sample[2] !== pid) { + continue + } + if (sample[3] === 'S' || sample[3] === 'R') { + freezeStart = '' + freezeSamples = 0 + } else { + if (freezeSamples === 0) { + freezeStart = new Date(Number(sample[1]) * 1000) + .toISOString() + .slice(11, 19) + } + freezeSamples += 1 + } + } const exitedAt = Number(epoch) // A run's own DONE is the only one its emulator's exit can follow. Only a log with no start lines counts every // DONE, and when start lines exist but none is at or before the exit, no run had started and the exit is mid-run. @@ -179,7 +203,7 @@ export function hostEvidence(hostLog, dmesg, timing) { ) { verdict = ', emulator crashed' } - return `host evidence: emulator process ${pid} exited at ${clock} UTC, ${where}${verdict}` + return `host evidence: emulator process ${pid} exited at ${clock} UTC, ${where}${verdict}${freezeSamples >= 2 ? `, emulator froze at ${freezeStart} UTC` : ''}` }) } diff --git a/.github/scripts/emulator-runs.test.ts b/.github/scripts/emulator-runs.test.ts index ce96bdf2a5..326b1f12c5 100644 --- a/.github/scripts/emulator-runs.test.ts +++ b/.github/scripts/emulator-runs.test.ts @@ -1951,7 +1951,7 @@ describe('the workflow', () => { workflow.split(` - name: ${name}\n`)[1]?.split('\n - ')[0] ?? '' const key = "u2-jar-${{ hashFiles('packages/thumbjar/src/device/u2-jar.json') }}" - const path = 'packages/thumbjar/.cache/u2.jar' + const path = 'packages/editor/node_modules/.cache/thumbjar/u2.jar' expect({ order: names.slice( names.indexOf('Restore the uiautomator2 server jar'), diff --git a/.github/scripts/host-evidence.test.ts b/.github/scripts/host-evidence.test.ts index 93321e8daa..58db6a1afa 100644 --- a/.github/scripts/host-evidence.test.ts +++ b/.github/scripts/host-evidence.test.ts @@ -3,6 +3,115 @@ import {hostEvidence} from './emulator-runs.mjs' // 1762772400 is 11:00:00 UTC. describe(hostEvidence.name, () => { + test('Scenario: A sustained freeze precedes exit', () => { + expect( + hostEvidence( + [ + '1762772397 812000 0 1.50 qemu pid=4242 rss_kb=4100000 state=S', + '1762772398 812000 0 1.50 qemu pid=4242 rss_kb=4100000 state=I', + '1762772399 812000 0 1.50 qemu pid=4242 rss_kb=4100000 state=I', + '1762772402 812000 0 1.50 qemu=none', + 'QEMU_GONE pid=4242 at 1762772402', + ].join('\n'), + '', + '', + ), + ).toEqual([ + 'host evidence: emulator process 4242 exited at 11:00:02 UTC, mid-run, emulator froze at 10:59:58 UTC', + ]) + }) + + test('Scenario: A stopped emulator precedes exit', () => { + expect( + hostEvidence( + [ + '1762772397 812000 0 1.50 qemu pid=4242 rss_kb=4100000 state=T', + '1762772398 812000 0 1.50 qemu pid=4242 rss_kb=4100000 state=T', + '1762772402 812000 0 1.50 qemu=none', + 'QEMU_GONE pid=4242 at 1762772402', + ].join('\n'), + '', + '', + ), + ).toEqual([ + 'host evidence: emulator process 4242 exited at 11:00:02 UTC, mid-run, emulator froze at 10:59:57 UTC', + ]) + }) + + test('Scenario: One abnormal sample precedes exit', () => { + expect( + hostEvidence( + [ + '1762772397 812000 0 1.50 qemu pid=4242 rss_kb=4100000 state=S', + '1762772398 812000 0 1.50 qemu pid=4242 rss_kb=4100000 state=I', + '1762772402 812000 0 1.50 qemu=none', + 'QEMU_GONE pid=4242 at 1762772402', + ].join('\n'), + '', + '', + ), + ).toEqual([ + 'host evidence: emulator process 4242 exited at 11:00:02 UTC, mid-run', + ]) + }) + + test('Scenario: The emulator recovers before exit', () => { + expect( + hostEvidence( + [ + '1762772397 812000 0 1.50 qemu pid=4242 rss_kb=4100000 state=I', + '1762772398 812000 0 1.50 qemu pid=4242 rss_kb=4100000 state=I', + '1762772399 812000 0 1.50 qemu pid=4242 rss_kb=4100000 state=R', + '1762772402 812000 0 1.50 qemu=none', + 'QEMU_GONE pid=4242 at 1762772402', + ].join('\n'), + '', + '', + ), + ).toEqual([ + 'host evidence: emulator process 4242 exited at 11:00:02 UTC, mid-run', + ]) + }) + + test('Scenario: A recovered freeze is followed by a new freeze', () => { + expect( + hostEvidence( + [ + '1762772397 812000 0 1.50 qemu pid=4242 rss_kb=4100000 state=I', + '1762772398 812000 0 1.50 qemu pid=4242 rss_kb=4100000 state=I', + '1762772399 812000 0 1.50 qemu pid=4242 rss_kb=4100000 state=S', + '1762772400 812000 0 1.50 qemu pid=4242 rss_kb=4100000 state=T', + '1762772401 812000 0 1.50 qemu pid=4242 rss_kb=4100000 state=T', + '1762772402 812000 0 1.50 qemu=none', + 'QEMU_GONE pid=4242 at 1762772402', + ].join('\n'), + '', + '', + ), + ).toEqual([ + 'host evidence: emulator process 4242 exited at 11:00:02 UTC, mid-run, emulator froze at 11:00:00 UTC', + ]) + }) + + test('Scenario: A retry cannot inherit the previous emulator freeze', () => { + expect( + hostEvidence( + [ + '1762772397 812000 0 1.50 qemu pid=4242 rss_kb=4100000 state=I', + '1762772398 812000 0 1.50 qemu pid=4242 rss_kb=4100000 state=I', + 'QEMU_GONE pid=4242 at 1762772400', + '1762772401 812000 0 1.50 qemu pid=5151 rss_kb=4100000 state=S', + 'QEMU_GONE pid=5151 at 1762772402', + ].join('\n'), + '', + '', + ), + ).toEqual([ + 'host evidence: emulator process 4242 exited at 11:00:00 UTC, mid-run, emulator froze at 10:59:57 UTC', + 'host evidence: emulator process 5151 exited at 11:00:02 UTC, mid-run', + ]) + }) + test('Scenario: The emulator exits mid-run and the kernel log names an out of memory kill', () => { expect( hostEvidence( diff --git a/.github/scripts/host-sampler.sh b/.github/scripts/host-sampler.sh index 2f7440a5e3..f2f749f641 100755 --- a/.github/scripts/host-sampler.sh +++ b/.github/scripts/host-sampler.sh @@ -19,6 +19,35 @@ echo "$$" > "$out_dir/host-sampler.pid" || true self="$BASHPID" last_pid="" +freeze_samples=0 +freeze_count=0 +freeze_at=0 + +capture_threads() { + stack_available=false + if timeout --kill-after=1 1 sudo -n true 2> /dev/null; then + stack_available=true + fi + for task in /proc/"$last_pid"/task/*; do + [ -r "$task/stat" ] || continue + task_stat="$(cat "$task/stat" 2> /dev/null)" + task_rest="${task_stat##*) }" + task_state="${task_rest%% *}" + task_comm="$(cat "$task/comm" 2> /dev/null)" + task_wchan="$(cat "$task/wchan" 2> /dev/null)" + printf 'tid=%s comm=%s state=%s wchan=%s\n' "${task##*/}" "${task_comm:-unknown}" "${task_state:-unknown}" "${task_wchan:-unknown}" + if [ "$stack_available" = true ]; then + stack_status=0 + task_stack="$(timeout --kill-after=1 1 sudo -n cat "$task/stack" 2> /dev/null)" || stack_status=$? + if [ "$stack_status" -eq 0 ]; then + printf '%s\n' "$task_stack" + elif [ "$stack_status" -eq 124 ] || [ "$stack_status" -eq 137 ]; then + # A read that timed out would cost every later thread the same wait, so the rest of the capture has no stacks. + stack_available=false + fi + fi + done +} while true; do now="$(date +%s)" @@ -63,6 +92,27 @@ while true; do fi if [ -z "$last_pid" ] && [ -n "$current_pid" ] && [ "$qemu" != "qemu=none" ]; then last_pid="$current_pid" + freeze_samples=0 + freeze_count=0 + freeze_at=0 + fi + + if [ -n "$last_pid" ] && [ "$current_pid" = "$last_pid" ] && [ "$qemu" != "qemu=none" ]; then + if [ "$state" = "S" ] || [ "$state" = "R" ]; then + freeze_samples=0 + freeze_count=0 + else + freeze_samples=$((freeze_samples + 1)) + if { [ "$freeze_count" -eq 0 ] && [ "$freeze_samples" -ge 2 ]; } || + { [ "$freeze_count" -eq 1 ] && [ "$((now - freeze_at))" -ge 30 ]; }; then + freeze_count=$((freeze_count + 1)) + freeze_at="$now" + if [ "$freeze_count" -eq 1 ]; then + rm -f "$out_dir/host-freeze-$last_pid-2.txt" 2> /dev/null || true + fi + capture_threads > "$out_dir/host-freeze-$last_pid-$freeze_count.txt" 2> /dev/null || true + fi + fi fi sleep "$interval" || true diff --git a/.github/scripts/host-scripts.test.ts b/.github/scripts/host-scripts.test.ts index ffb53d1767..c0c1b248c5 100644 --- a/.github/scripts/host-scripts.test.ts +++ b/.github/scripts/host-scripts.test.ts @@ -20,6 +20,412 @@ const capture = fileURLToPath(new URL('host-capture.sh', import.meta.url)) const describeOnLinux = describe.runIf(process.platform === 'linux') describeOnLinux('host-sampler.sh and host-capture.sh', () => { + test('Scenario: A stopped emulator produces two per-thread captures at least 30 seconds apart', async () => { + const directory = mkdtempSync(join(tmpdir(), 'host-freeze-')) + const hostLog = join(directory, 'host.log') + writeFileSync( + join(directory, 'sudo'), + '#!/bin/sh\nprintf unavailable >&2\nexit 1\n', + {mode: 0o755}, + ) + const fakeEmulator = spawn('sleep', ['137'], {stdio: 'ignore'}) + const samplerProcess = spawn( + 'bash', + ['-e', '-o', 'pipefail', sampler, hostLog], + { + env: { + ...process.env, + PATH: `${directory}:${process.env.PATH}`, + HOST_SAMPLER_PATTERN: '^sleep 137$', + HOST_SAMPLER_INTERVAL: '0.2', + }, + stdio: 'ignore', + }, + ) + try { + await vi.waitFor( + () => { + expect(readFileSync(hostLog, 'utf8')).toContain( + `qemu pid=${fakeEmulator.pid} `, + ) + }, + {timeout: 10_000}, + ) + fakeEmulator.kill('SIGSTOP') + await vi.waitFor( + () => { + expect( + readFileSync( + join(directory, `host-freeze-${fakeEmulator.pid}-1.txt`), + 'utf8', + ), + ).toEqual( + expect.stringMatching( + new RegExp( + `^tid=${fakeEmulator.pid} comm=sleep state=T wchan=\\S+\\n$`, + ), + ), + ) + }, + {timeout: 10_000}, + ) + const firstCapture = Number( + readFileSync(hostLog, 'utf8').trim().split('\n').at(-1)?.split(' ')[0], + ) + await vi.waitFor( + () => { + expect( + readFileSync( + join(directory, `host-freeze-${fakeEmulator.pid}-2.txt`), + 'utf8', + ), + ).toEqual( + expect.stringMatching( + new RegExp( + `^tid=${fakeEmulator.pid} comm=sleep state=T wchan=\\S+\\n$`, + ), + ), + ) + }, + {timeout: 40_000}, + ) + const secondCapture = Number( + readFileSync(hostLog, 'utf8').trim().split('\n').at(-1)?.split(' ')[0], + ) + expect(secondCapture - firstCapture >= 30).toEqual(true) + fakeEmulator.kill('SIGKILL') + await vi.waitFor( + () => { + expect(readFileSync(hostLog, 'utf8')).toContain('QEMU_GONE') + }, + {timeout: 10_000}, + ) + expect( + readdirSync(directory) + .filter((name) => name.startsWith('host-freeze-')) + .sort(), + ).toEqual([ + `host-freeze-${fakeEmulator.pid}-1.txt`, + `host-freeze-${fakeEmulator.pid}-2.txt`, + ]) + } finally { + fakeEmulator.kill('SIGKILL') + samplerProcess.kill('SIGKILL') + rmSync(directory, {recursive: true, force: true}) + } + }, 60_000) + + test('Scenario: A kernel stack read that ignores SIGTERM does not stop the sampler', async () => { + const directory = mkdtempSync(join(tmpdir(), 'host-freeze-')) + const hostLog = join(directory, 'host.log') + writeFileSync( + join(directory, 'sudo'), + "#!/bin/sh\ntrap '' TERM\nwhile true; do sleep 0.1; done\n", + {mode: 0o755}, + ) + const fakeEmulator = spawn('sleep', ['139'], {stdio: 'ignore'}) + const samplerProcess = spawn( + 'bash', + ['-e', '-o', 'pipefail', sampler, hostLog], + { + env: { + ...process.env, + PATH: `${directory}:${process.env.PATH}`, + HOST_SAMPLER_PATTERN: '^sleep 139$', + HOST_SAMPLER_INTERVAL: '0.2', + }, + stdio: 'ignore', + }, + ) + try { + await vi.waitFor( + () => { + expect(readFileSync(hostLog, 'utf8')).toContain( + `qemu pid=${fakeEmulator.pid} `, + ) + }, + {timeout: 10_000}, + ) + const stoppedAt = Math.floor(Date.now() / 1000) + fakeEmulator.kill('SIGSTOP') + await vi.waitFor( + () => { + expect( + existsSync( + join(directory, `host-freeze-${fakeEmulator.pid}-1.txt`), + ), + ).toEqual(true) + }, + {timeout: 10_000}, + ) + await vi.waitFor( + () => { + const lastSample = Number( + readFileSync(hostLog, 'utf8') + .trim() + .split('\n') + .at(-1) + ?.split(' ')[0], + ) + expect(lastSample - stoppedAt >= 6).toEqual(true) + }, + {timeout: 15_000}, + ) + } finally { + fakeEmulator.kill('SIGKILL') + samplerProcess.kill('SIGKILL') + rmSync(directory, {recursive: true, force: true}) + } + }, 40_000) + + test('Scenario: A hanging sudo with several threads leaves no sample gap above four seconds', async () => { + const fixture = startFreezeSampler('hanging') + try { + await vi.waitFor( + () => { + expect(readFileSync(fixture.hostLog, 'utf8')).toContain( + `qemu pid=${fixture.fakeEmulator.pid} `, + ) + expect( + readdirSync(`/proc/${fixture.fakeEmulator.pid}/task`).length >= 4, + ).toEqual(true) + }, + {timeout: 10_000}, + ) + fixture.fakeEmulator.kill('SIGSTOP') + await vi.waitFor( + () => { + expect(existsSync(fixture.firstCapture)).toEqual(true) + }, + {timeout: 10_000}, + ) + const captureSample = readFileSync(fixture.hostLog, 'utf8') + .trim() + .split('\n') + .at(-1)! + await vi.waitFor( + () => { + expect( + readFileSync(fixture.hostLog, 'utf8').trim().split('\n').at(-1) === + captureSample, + ).toEqual(false) + }, + {timeout: 25_000}, + ) + const samples = readFileSync(fixture.hostLog, 'utf8') + .trim() + .split('\n') + .map((line) => Number(line.split(' ')[0])) + expect( + Math.max( + ...samples.slice(1).map((sample, index) => sample - samples[index]!), + ) <= 4, + ).toEqual(true) + } finally { + fixture.cleanup() + } + }, 40_000) + + test('Scenario: A stack read that hangs after sudo answers leaves no sample gap above four seconds', async () => { + const fixture = startFreezeSampler('hanging-stack') + try { + await vi.waitFor( + () => { + expect(readFileSync(fixture.hostLog, 'utf8')).toContain( + `qemu pid=${fixture.fakeEmulator.pid} `, + ) + expect( + readdirSync(`/proc/${fixture.fakeEmulator.pid}/task`).length >= 4, + ).toEqual(true) + }, + {timeout: 10_000}, + ) + fixture.fakeEmulator.kill('SIGSTOP') + await vi.waitFor( + () => { + expect(existsSync(fixture.firstCapture)).toEqual(true) + }, + {timeout: 10_000}, + ) + const captureSample = readFileSync(fixture.hostLog, 'utf8') + .trim() + .split('\n') + .at(-1)! + await vi.waitFor( + () => { + expect( + readFileSync(fixture.hostLog, 'utf8').trim().split('\n').at(-1) === + captureSample, + ).toEqual(false) + }, + {timeout: 25_000}, + ) + const samples = readFileSync(fixture.hostLog, 'utf8') + .trim() + .split('\n') + .map((line) => Number(line.split(' ')[0])) + expect( + Math.max( + ...samples.slice(1).map((sample, index) => sample - samples[index]!), + ) <= 4, + ).toEqual(true) + } finally { + fixture.cleanup() + } + }, 40_000) + + test('Scenario: A recovered freeze cannot use a later single stopped sample for capture two', async () => { + const fixture = startFreezeSampler('unavailable') + try { + await vi.waitFor( + () => { + expect(readFileSync(fixture.hostLog, 'utf8')).toContain( + `qemu pid=${fixture.fakeEmulator.pid} `, + ) + }, + {timeout: 10_000}, + ) + fixture.fakeEmulator.kill('SIGSTOP') + await vi.waitFor( + () => { + expect(readFileSync(fixture.firstCapture, 'utf8')).toContain('tid=') + }, + {timeout: 10_000}, + ) + const firstSample = Number( + readFileSync(fixture.hostLog, 'utf8') + .trim() + .split('\n') + .at(-1)! + .split(' ')[0], + ) + fixture.fakeEmulator.kill('SIGCONT') + await vi.waitFor( + () => { + const lastSample = readFileSync(fixture.hostLog, 'utf8') + .trim() + .split('\n') + .at(-1)! + expect(lastSample).toEqual(expect.stringMatching(/state=[SR]$/)) + expect(Number(lastSample.split(' ')[0]) - firstSample > 30).toEqual( + true, + ) + }, + {timeout: 40_000}, + ) + const beforeStop = readFileSync(fixture.hostLog, 'utf8') + .trim() + .split('\n').length + fixture.fakeEmulator.kill('SIGSTOP') + await vi.waitFor( + () => { + expect( + readFileSync(fixture.hostLog, 'utf8') + .trim() + .split('\n') + .slice(beforeStop) + .some((line) => line.endsWith('state=T')), + ).toEqual(true) + }, + {timeout: 10_000, interval: 10}, + ) + fixture.fakeEmulator.kill('SIGCONT') + await vi.waitFor( + () => { + expect( + readFileSync(fixture.hostLog, 'utf8').trim().split('\n').at(-1), + ).toEqual(expect.stringMatching(/state=[SR]$/)) + }, + {timeout: 10_000}, + ) + expect(existsSync(fixture.secondCapture)).toEqual(false) + } finally { + fixture.cleanup() + } + }, 60_000) + + test('Scenario: A new freeze rewrites capture one and removes the previous freeze capture two', async () => { + const fixture = startFreezeSampler('unavailable') + try { + await vi.waitFor( + () => { + expect(readFileSync(fixture.hostLog, 'utf8')).toContain( + `qemu pid=${fixture.fakeEmulator.pid} `, + ) + }, + {timeout: 10_000}, + ) + fixture.fakeEmulator.kill('SIGSTOP') + await vi.waitFor( + () => { + expect(readFileSync(fixture.secondCapture, 'utf8')).toContain('tid=') + }, + {timeout: 40_000}, + ) + fixture.fakeEmulator.kill('SIGCONT') + await vi.waitFor( + () => { + expect( + readFileSync(fixture.hostLog, 'utf8').trim().split('\n').at(-1), + ).toEqual(expect.stringMatching(/state=[SR]$/)) + }, + {timeout: 10_000}, + ) + writeFileSync(fixture.firstCapture, 'previous freeze\n') + fixture.fakeEmulator.kill('SIGSTOP') + await vi.waitFor( + () => { + expect(readFileSync(fixture.firstCapture, 'utf8')).toContain('tid=') + expect(existsSync(fixture.secondCapture)).toEqual(false) + }, + {timeout: 5_000}, + ) + } finally { + fixture.cleanup() + } + }, 60_000) + + test('Scenario: Successful sudo writes each stack after its thread line', async () => { + const fixture = startFreezeSampler('available') + try { + await vi.waitFor( + () => { + expect(readFileSync(fixture.hostLog, 'utf8')).toContain( + `qemu pid=${fixture.fakeEmulator.pid} `, + ) + }, + {timeout: 10_000}, + ) + fixture.fakeEmulator.kill('SIGSTOP') + await vi.waitFor( + () => { + const threadIds = readdirSync( + `/proc/${fixture.fakeEmulator.pid}/task`, + ).sort() + const lines = readFileSync(fixture.firstCapture, 'utf8') + .trim() + .split('\n') + expect(lines.filter((_, index) => index % 2 === 0).sort()).toEqual( + threadIds.map((threadId) => + expect.stringMatching( + new RegExp(`^tid=${threadId} comm=.+ state=T wchan=\\S+$`), + ), + ), + ) + expect(lines.filter((_, index) => index % 2 === 1).sort()).toEqual( + threadIds.map( + (threadId) => + `stack /proc/${fixture.fakeEmulator.pid}/task/${threadId}/stack`, + ), + ) + }, + {timeout: 10_000}, + ) + } finally { + fixture.cleanup() + } + }, 30_000) + test('Scenario: The emulator process is killed and the capture stops the sampler', async () => { const directory = mkdtempSync(join(tmpdir(), 'host-scripts-')) const crashDirectory = join(directory, 'crash') @@ -229,3 +635,51 @@ function isAlive(pid: number) { return false } } + +function startFreezeSampler( + sudo: 'hanging' | 'hanging-stack' | 'unavailable' | 'available', +) { + const directory = mkdtempSync(join(tmpdir(), 'host-freeze-regression-')) + const hostLog = join(directory, 'host.log') + writeFileSync( + join(directory, 'sudo'), + sudo === 'hanging' + ? "#!/bin/sh\ntrap '' TERM\nwhile true; do sleep 0.1; done\n" + : sudo === 'hanging-stack' + ? '#!/bin/sh\nif [ "$2" = true ]; then exit 0; fi\ntrap \'\' TERM\nwhile true; do sleep 0.1; done\n' + : sudo === 'available' + ? '#!/bin/sh\nshift\nif [ "$1" = true ]; then exit 0; fi\nshift\nprintf "stack %s\\n" "$1"\n' + : '#!/bin/sh\nexit 1\n', + {mode: 0o755}, + ) + const emulatorScript = join(directory, 'emulator.cjs') + writeFileSync(emulatorScript, 'setInterval(() => {}, 1000)\n') + const fakeEmulator = spawn(process.execPath, [emulatorScript], { + stdio: 'ignore', + detached: true, + }) + const samplerProcess = spawn( + 'bash', + ['-e', '-o', 'pipefail', sampler, hostLog], + { + env: { + ...process.env, + PATH: `${directory}:${process.env.PATH}`, + HOST_SAMPLER_PATTERN: emulatorScript, + HOST_SAMPLER_INTERVAL: '0.2', + }, + stdio: 'ignore', + }, + ) + return { + hostLog, + fakeEmulator, + firstCapture: join(directory, `host-freeze-${fakeEmulator.pid}-1.txt`), + secondCapture: join(directory, `host-freeze-${fakeEmulator.pid}-2.txt`), + cleanup() { + fakeEmulator.kill('SIGKILL') + samplerProcess.kill('SIGKILL') + rmSync(directory, {recursive: true, force: true}) + }, + } +} diff --git a/.github/workflows/android-ime.yml b/.github/workflows/android-ime.yml index 479569f75a..01b0af59cf 100644 --- a/.github/workflows/android-ime.yml +++ b/.github/workflows/android-ime.yml @@ -105,9 +105,9 @@ jobs: pnpm --filter @portabletext/editor build:android node -e "console.log('EDITOR_VERSION', require('./packages/editor/package.json').version)" - # The jar comes from the actions cache when an earlier run saved it, under a key that changes with its - # URL and hash, see the jar cache in `packages/editor/android/README.md`. A failed restore or save is a - # miss, which downloads as before. + # The jar comes from the actions cache when an earlier run saved it, under the editor package's cache + # directory and a key that changes with its URL and hash, see the jar cache in + # `packages/editor/android/README.md`. A failed restore or save is a miss, which downloads as before. - name: Restore the uiautomator2 server jar id: u2-jar-restore continue-on-error: true @@ -115,7 +115,7 @@ jobs: uses: actions/cache/restore@v6 with: key: u2-jar-${{ hashFiles('packages/thumbjar/src/device/u2-jar.json') }} - path: packages/thumbjar/.cache/u2.jar + path: packages/editor/node_modules/.cache/thumbjar/u2.jar # A restored jar whose hash differs is downloaded again. - name: Fetch the uiautomator2 server jar @@ -131,7 +131,7 @@ jobs: uses: actions/cache/save@v6 with: key: u2-jar-${{ hashFiles('packages/thumbjar/src/device/u2-jar.json') }} - path: packages/thumbjar/.cache/u2.jar + path: packages/editor/node_modules/.cache/thumbjar/u2.jar # The emulator and its system image come from the actions cache when it holds the versions a fresh # install picks today, see the SDK cache in `packages/editor/android/README.md`. A failed lookup, diff --git a/apps/doc-examples/package.json b/apps/doc-examples/package.json index 1423e00aa8..b488a224ca 100644 --- a/apps/doc-examples/package.json +++ b/apps/doc-examples/package.json @@ -12,7 +12,7 @@ "@portabletext/editor": "workspace:*", "@portabletext/html": "workspace:*", "@portabletext/keyboard-shortcuts": "workspace:*", - "@portabletext/markdown": "^2.9.3", + "@portabletext/markdown": "^2.9.4", "@portabletext/patches": "workspace:*", "@portabletext/plugin-character-pair-decorator": "workspace:*", "@portabletext/plugin-decorations": "workspace:*", diff --git a/apps/playground/package.json b/apps/playground/package.json index d86c30ecbb..c820f0ef2a 100644 --- a/apps/playground/package.json +++ b/apps/playground/package.json @@ -16,7 +16,7 @@ "@portabletext/editor": "workspace:*", "@portabletext/html": "workspace:*", "@portabletext/keyboard-shortcuts": "workspace:*", - "@portabletext/markdown": "^2.9.3", + "@portabletext/markdown": "^2.9.4", "@portabletext/patches": "workspace:*", "@portabletext/plugin-decorations": "workspace:*", "@portabletext/plugin-dnd": "workspace:*", diff --git a/knip.json b/knip.json index 5cc36fadcb..b31f0a48bb 100644 --- a/knip.json +++ b/knip.json @@ -69,6 +69,7 @@ "src/vitest/global-setup.ts!", "src/vitest/setup.ts!", "src/bin.ts!", + "src/guard.ts!", "src/prepare/*.ts!", "src/run/*.ts!", "package.config.ts!", diff --git a/packages/editor/android/README.md b/packages/editor/android/README.md index 2c377bb24a..64b50120e1 100644 --- a/packages/editor/android/README.md +++ b/packages/editor/android/README.md @@ -48,17 +48,17 @@ Each job retries once, on a fresh emulator, when the first emulator never booted Each job uploads an `android-ime--api36-` artifact, `android-ime-ja-api36-1` for `tests/ja.test.ts`, with `vitest.log`, `phases.log`, `logcat.txt`, `diagnose.txt` and thumbjar's per-test artifacts: the page's state after each word as `-.json`, screenshots and Gboard accessibility dumps. -The artifact also records why the emulator process exited, when it did. `host.log` has a line every 2 seconds with the host's free memory, swap and load and the emulator process's size and state, and a `QEMU_GONE` line when a process it followed disappears. `host-dmesg.txt` is the host's kernel log, `host-disk.txt` and `host-memory.txt` hold `df -h`, `free -m` and `/proc/meminfo` from the end of the job, and `emu-crash/` holds the emulator's crash data when it wrote any. The job summary gets a `host evidence:` line for each exit, with its time, whether it came at teardown or mid-run, and `host OOM` or `emulator crashed` when the kernel log names the process. A job with no exit says so. These lines never change the `run outcome` line. +The artifact also records why the emulator process exited, when it did. `host.log` has a line every 2 seconds with the host's free memory, swap and load and the emulator process's size and state, and a `QEMU_GONE` line when a process it followed disappears. `host-dmesg.txt` is the host's kernel log, `host-disk.txt` and `host-memory.txt` hold `df -h`, `free -m` and `/proc/meminfo` from the end of the job, and `emu-crash/` holds the emulator's crash data when it wrote any. The job summary gets a `host evidence:` line for each exit, with its time, whether it came at teardown or mid-run, and `host OOM` or `emulator crashed` when the kernel log names the process. After two consecutive samples outside `S` or `R`, `host-freeze--1.txt` records each thread's TID, name, state and wait channel from `/proc`, plus its kernel stack when passwordless sudo permits it. A second capture, `host-freeze--2.txt`, follows at least 30 seconds later if the process is still outside `S` or `R`, and the two files describe the latest continuous freeze. After a recovery to `S` or `R`, a new freeze replaces the first capture and removes the previous second capture. The exit summary adds the first sample's freeze time when at least two consecutive abnormal samples precede that exit without a recovery. A job with no exit says so. These lines never change the `run outcome` line. The emulator and the `google_apis_playstore` x86_64 system image come from the actions cache when it holds the versions a fresh install picks. The runner image ships platform-tools and the platform, so they are not cached. The cache key names the runner OS, the API level, the image, and the image revision and emulator version that `sdkmanager --list` reports as available at the lookup, which takes about 3 seconds. A lookup that fails, or an SDK that already has one of the two packages, skips the cache. `sdkmanager --install` never upgrades or repairs an installed package, it only checks that its directory exists, so every save carries a manifest with the cache's key and each file in the two packages with its size. A file name with a tab, a newline or a carriage return fails the manifest, and nothing is saved. After a restore, `.github/scripts/emulator-runs.mjs cache-check` deletes both packages when the manifest names another key, and a package that has a file missing, a file added or a file of another size, or that the manifest does not list, and the install downloads it as on a miss. A check that fails or times out deletes both packages. Only runs on `main` save, the nightly schedule and a manual dispatch on `main`, after a successful install, and only when the manifest was written from the installed packages. Pull requests restore `main`'s entry and save none. A failed lookup, restore or save never fails the job or changes its result. The AVD and its snapshots are never cached, so every run boots a fresh emulator. -The uiautomator2 server jar comes from the actions cache under the key `u2-jar-`. That file pins the jar's URL and its sha256, so a new jar gets a new key. `thumbjar fetch-u2-jar` checks a restored jar's hash and downloads it again when the hash differs, and its log line says whether it downloaded the jar. After a fetch on a miss, the job saves the jar from any branch or pull request. A pull request's entry is read only by runs of that pull request, and `main`'s, which the nightly run and a manual dispatch on `main` save, by every branch. A failed restore or save never fails the job. +The uiautomator2 server jar at `packages/editor/node_modules/.cache/thumbjar/u2.jar` comes from the actions cache under the key `u2-jar-`. That file pins the jar's URL and its sha256, so a new jar gets a new key. `thumbjar fetch-u2-jar` checks a restored jar's hash and downloads it again when the hash differs, and its log line says whether it downloaded the jar. After a fetch on a miss, the job saves the jar from any branch or pull request. A pull request's entry is read only by runs of that pull request, and `main`'s, which the nightly run and a manual dispatch on `main` save, by every branch. A failed restore or save never fails the job. Before retrying installation, the job clears only new `PackageOperation` directories under `$ANDROID_HOME/.temp` and new package directories from that install, and the packages the cache check kept, preserving other paths present before the attempt. These hold the downloaded archive, unpacked files and installer resume metadata. It does not clear the SDK's other packages or `~/.android/cache`. If `install-start` failed before recording its snapshot, sdkmanager never ran, so the retry clears nothing and reports the missing snapshot. ### Time budget -thumbjar bounds each `typeOnDevice` and `runOnDevice` call and the device prep. `thumbjar run` runs each stage under `timeout -k`, with the test stage's cap read from `deviceTestFileSeconds` for the file's test count, and logs why when it stops one. The workflow's `timeout-minutes` are flat backstops, including for a hang inside the emulator runner action. A run a step's cap stops after boot, before its device check completes, is not retried and its result says so, see the retry above. +thumbjar bounds each `typeOnDevice` and `runOnDevice` call and the device prep. `thumbjar run` gives the cap read and device check 10 seconds with 5 to stop, the test stage `deviceTestFileSeconds` for the file's test count with 10 to stop, and the final diagnostics 60 seconds with 5 to stop. Node guards enforce the child deadlines with SIGTERM followed by SIGKILL even if the run process is killed. Prep enforces its own per-language deadline, and the server stops when its connection to the run process closes. The workflow's `timeout-minutes` are flat backstops, including for a hang inside the emulator runner action. A run a step's cap stops after boot, before its device check completes, is not retried and its result says so, see the retry above. ## Running it @@ -77,6 +77,8 @@ cd packages/editor pnpm exec thumbjar run tests/ja.test.ts --keyboard ja --page-dir android/dist --config vitest.android.config.ts --project device --out-dir android/out ``` +The filtered fetch runs with `packages/editor` as its current working directory, so it uses `packages/editor/node_modules/.cache/thumbjar/u2.jar`. + `thumbjar run` expects a freshly booted emulator. It runs from `packages/editor`, as the workflow does, serves `android/dist/` on port 8765, prepares the device for `--keyboard` with `thumbjar prepare`, runs the one test file it is given and writes everything to `android/out/`. Once a device is prepared and the server runs, run the tests again without the prep, from `packages/editor`: diff --git a/packages/editor/android/scripts/device-suites.test.ts b/packages/editor/android/scripts/device-suites.test.ts index 7852f4775a..85f6f25712 100644 --- a/packages/editor/android/scripts/device-suites.test.ts +++ b/packages/editor/android/scripts/device-suites.test.ts @@ -34,6 +34,64 @@ describe('the Japanese device tests', () => { }) }, 30_000) + test('passes every case when Space converts a word to kanji', async () => { + expect( + await runSuite({ + root, + file: 'tests/ja.test.ts', + setupFiles: [ + 'scripts/fixtures/suites/typing-commits-kanji-with-space.ts', + ], + }), + ).toEqual({ + file: 'passed', + errors: [], + tests: [ + { + name: 'Scenario: Typing kyou twice into a plain contenteditable', + state: 'passed', + }, + { + name: 'Scenario: Typing kyou twice into an empty block', + state: 'passed', + }, + { + name: 'Scenario: Typing kyou twice after existing text', + state: 'passed', + }, + ], + }) + }, 30_000) + + test('fails every case when a tapped kanji candidate remains kana', async () => { + expect( + await runSuite({ + root, + file: 'tests/ja.test.ts', + setupFiles: [ + 'scripts/fixtures/suites/typing-keeps-kana-after-picking-kanji.ts', + ], + }), + ).toEqual({ + file: 'failed', + errors: [], + tests: [ + { + name: 'Scenario: Typing kyou twice into a plain contenteditable', + state: 'failed', + }, + { + name: 'Scenario: Typing kyou twice into an empty block', + state: 'failed', + }, + { + name: 'Scenario: Typing kyou twice after existing text', + state: 'failed', + }, + ], + }) + }, 30_000) + test('fails every case when the text is right but the page recorded no composition', async () => { expect( await runSuite({ diff --git a/packages/editor/android/scripts/fixtures/suites/typing-commits-kanji-with-space.ts b/packages/editor/android/scripts/fixtures/suites/typing-commits-kanji-with-space.ts new file mode 100644 index 0000000000..69f28c6a87 --- /dev/null +++ b/packages/editor/android/scripts/fixtures/suites/typing-commits-kanji-with-space.ts @@ -0,0 +1,12 @@ +import {vi} from 'vitest' + +vi.mock(import('thumbjar'), () => ({ + typeOnDevice: (_context: unknown, {prefix}: {prefix: string}) => + Promise.resolve({ + text: `${prefix}今日今日`, + picked: ['space', '今日'], + compositionstart: 2, + nested: 0, + compositionstartPerWord: [1, 1], + }), +})) diff --git a/packages/editor/android/scripts/fixtures/suites/typing-keeps-kana-after-picking-kanji.ts b/packages/editor/android/scripts/fixtures/suites/typing-keeps-kana-after-picking-kanji.ts new file mode 100644 index 0000000000..c33a4ed71b --- /dev/null +++ b/packages/editor/android/scripts/fixtures/suites/typing-keeps-kana-after-picking-kanji.ts @@ -0,0 +1,12 @@ +import {vi} from 'vitest' + +vi.mock(import('thumbjar'), () => ({ + typeOnDevice: (_context: unknown, {prefix}: {prefix: string}) => + Promise.resolve({ + text: `${prefix}きょう今日`, + picked: ['今日', '今日'], + compositionstart: 2, + nested: 0, + compositionstartPerWord: [1, 1], + }), +})) diff --git a/packages/editor/android/tests/ja.test.ts b/packages/editor/android/tests/ja.test.ts index 658e0335b8..c3af950ee6 100644 --- a/packages/editor/android/tests/ja.test.ts +++ b/packages/editor/android/tests/ja.test.ts @@ -12,9 +12,11 @@ test('Scenario: Typing kyou twice into a plain contenteditable', async (context) ], }) - // Gboard sometimes commits a word as kana before its conversion candidates load, and the candidates - // can load between the two words, so each word is either 今日 or きょう in every test here. - expect(typed.text).toMatch(/^(今日|きょう){2}$/) + // A word thumbjar committed with Space may stay kana or be converted by it, but a word it committed by + // tapping the `今日` candidate must read `今日`. + const firstWord = typed.picked[0] === 'space' ? '(今日|きょう)' : '今日' + const secondWord = typed.picked[1] === 'space' ? '(今日|きょう)' : '今日' + expect(typed.text).toMatch(new RegExp(`^${firstWord}${secondWord}$`)) expect({ nested: typed.nested, compositionstartPerWord: typed.compositionstartPerWord, @@ -32,7 +34,9 @@ test('Scenario: Typing kyou twice into an empty block', async (context) => { ], }) - expect(typed.text).toMatch(/^(今日|きょう){2}$/) + const firstWord = typed.picked[0] === 'space' ? '(今日|きょう)' : '今日' + const secondWord = typed.picked[1] === 'space' ? '(今日|きょう)' : '今日' + expect(typed.text).toMatch(new RegExp(`^${firstWord}${secondWord}$`)) expect({ nested: typed.nested, compositionstartPerWord: typed.compositionstartPerWord, @@ -50,7 +54,9 @@ test('Scenario: Typing kyou twice after existing text', async (context) => { ], }) - expect(typed.text).toMatch(/^x(今日|きょう){2}$/) + const firstWord = typed.picked[0] === 'space' ? '(今日|きょう)' : '今日' + const secondWord = typed.picked[1] === 'space' ? '(今日|きょう)' : '今日' + expect(typed.text).toMatch(new RegExp(`^x${firstWord}${secondWord}$`)) expect({ nested: typed.nested, compositionstartPerWord: typed.compositionstartPerWord, diff --git a/packages/editor/src/editor/sync-machine.ts b/packages/editor/src/editor/sync-machine.ts index a4d3166004..a0735366e3 100644 --- a/packages/editor/src/editor/sync-machine.ts +++ b/packages/editor/src/editor/sync-machine.ts @@ -781,6 +781,7 @@ function syncBlock({ validationValue, context.schema, context.keyGenerator, + index, ) if (!validation.valid && !validation.resolution?.autoResolve) { @@ -1267,22 +1268,26 @@ function updateBlock({ schemaTypes: context.schema, }) - // Strip `children`: they're synced by dedicated child operations below. - const {children: _children, ...blockProps} = engineBlock as unknown as Record< - string, - unknown - > + const blockIsTextBlock = isTextBlock({schema: context.schema}, engineBlock) + + // A text block's `children` are synced by the child operations below. Any + // other block's `children` is an ordinary field. + const {children: _children, ...propsWithoutChildren} = + engineBlock as unknown as Record + + const blockProps = blockIsTextBlock ? propsWithoutChildren : engineBlock applyNodeProperties(editorEngine, blockProps, [{_key: oldEngineBlock._key}]) // Remove properties present on the old node but absent from the new block. - // Skip children/text (structural, managed by dedicated operations). + // Skip `text`, and a text block's `children`, which the child operations + // below sync. const oldRecord = oldEngineBlock as unknown as Record const newRecord = engineBlock as unknown as Record const removedProperties: Record = {} for (const key of Object.keys(oldRecord)) { - if (key === 'children' || key === 'text') { + if (key === 'text' || (key === 'children' && blockIsTextBlock)) { continue } @@ -1299,7 +1304,7 @@ function updateBlock({ // Text block's need to have their children updated as well (setNode does not target a node's children) if ( - isTextBlock({schema: editorEngine.snapshot.context.schema}, engineBlock) && + blockIsTextBlock && isTextBlock({schema: editorEngine.snapshot.context.schema}, oldEngineBlock) ) { // Detect cases where incremental remove/insert would break due to diff --git a/packages/editor/src/engine/core/apply-operation.test.ts b/packages/editor/src/engine/core/apply-operation.test.ts index fa3dac51ba..089a343de2 100644 --- a/packages/editor/src/engine/core/apply-operation.test.ts +++ b/packages/editor/src/engine/core/apply-operation.test.ts @@ -1,16 +1,9 @@ -import { - compileSchema, - defineSchema, - type PortableTextBlock, -} from '@portabletext/schema' +import {type PortableTextBlock} from '@portabletext/schema' import {createTestKeyGenerator} from '@portabletext/test' import {describe, expect, test} from 'vitest' +import {createBareEditor} from '../../../test-utils/create-bare-editor.test-util' import {withRemoteChanges} from '../../engine-plugins/engine-plugin.remote-changes' -import {buildIndexMaps} from '../../internal-utils/build-index-maps' import {serializePath} from '../../paths/serialize-path' -import {resolveContainers} from '../../schema/resolve-containers-batch' -import type {PortableTextEditorEngine} from '../../types/editor-engine' -import {createEditor} from '../create-editor' import {applyOperation} from './apply-operation' import {inTestChange} from './change.test-utils' @@ -1069,38 +1062,3 @@ function textBlock({ ], } } - -function createBareEditor( - value: Array, -): PortableTextEditorEngine { - const schema = compileSchema( - defineSchema({ - annotations: [{name: 'link', fields: [{name: 'href', type: 'string'}]}], - blockObjects: [{name: 'image'}], - decorators: [{name: 'strong'}, {name: 'em'}], - }), - ) - const editor = createEditor() as PortableTextEditorEngine - const containers = resolveContainers(schema, []) - const blockIndexMap = new Map() - buildIndexMaps({schema, containers, value}, {blockIndexMap}) - - editor.containers = new Map() - editor.blockIndexMap = blockIndexMap - editor.verifiedUniqueChildGroups = new Set() - editor.snapshot = { - blockIndexMap, - context: { - containers, - converters: [], - keyGenerator: createTestKeyGenerator('generated-'), - readOnly: false, - schema, - selection: null, - value, - }, - decoratorState: {}, - } as PortableTextEditorEngine['snapshot'] - - return editor -} diff --git a/packages/editor/src/engine/core/normalize-node.test.ts b/packages/editor/src/engine/core/normalize-node.test.ts new file mode 100644 index 0000000000..fe854361f1 --- /dev/null +++ b/packages/editor/src/engine/core/normalize-node.test.ts @@ -0,0 +1,131 @@ +import {type PortableTextBlock} from '@portabletext/schema' +import {createTestKeyGenerator} from '@portabletext/test' +import {expect, test} from 'vitest' +import {createBareEditor} from '../../../test-utils/create-bare-editor.test-util' +import {withoutNormalizing} from '../editor/without-normalizing' +import type {EngineOperation} from '../interfaces/operation' +import {inTestChange} from './change.test-utils' +import {normalizeNode} from './normalize-node' + +test('a non-object root entry emits only an unset', () => { + const keyGenerator = createTestKeyGenerator() + const blockKey = keyGenerator() + const spanKey = keyGenerator() + const editor = createBareEditor( + [ + { + _type: 'block', + _key: blockKey, + children: [{_type: 'span', _key: spanKey, text: 'foo', marks: []}], + markDefs: [], + style: 'normal', + }, + ], + keyGenerator, + ) + editor.snapshot.context.value.push('oops' as never) + const operations: Array = [] + const apply = editor.apply + editor.apply = (operation) => { + operations.push(structuredClone(operation)) + return apply(operation) + } + + inTestChange(editor, () => normalizeNode(editor, ['oops' as never, [1]])) + + expect(operations).toEqual([ + { + type: 'unset', + path: [1], + }, + ]) + expect(editor.snapshot.context.value).toEqual([ + { + _type: 'block', + _key: blockKey, + children: [{_type: 'span', _key: spanKey, text: 'foo', marks: []}], + markDefs: [], + style: 'normal', + }, + ]) +}) + +test('a non-object child emits only an unset', () => { + const keyGenerator = createTestKeyGenerator() + const blockKey = keyGenerator() + const spanKey = keyGenerator() + const value = [ + { + _type: 'block', + _key: blockKey, + children: [{_type: 'span', _key: spanKey, text: 'foo', marks: []}], + markDefs: [], + style: 'normal', + }, + ] as Array + const editor = createBareEditor(value, keyGenerator) + const block = value[0] as {children: Array} + block.children.unshift(42) + const operations: Array = [] + const apply = editor.apply + editor.apply = (operation) => { + operations.push(structuredClone(operation)) + return apply(operation) + } + + inTestChange(editor, () => + normalizeNode(editor, [42 as never, [{_key: blockKey}, 'children', 0]]), + ) + + expect(operations).toEqual([ + { + type: 'unset', + path: [{_key: blockKey}, 'children', 0], + }, + ]) + expect(editor.snapshot.context.value).toEqual([ + { + _type: 'block', + _key: blockKey, + children: [{_type: 'span', _key: spanKey, text: 'foo', marks: []}], + markDefs: [], + style: 'normal', + }, + ]) +}) + +test('a removed path is not re-queued', () => { + const keyGenerator = createTestKeyGenerator() + const firstBlockKey = keyGenerator() + const firstSpanKey = keyGenerator() + const secondBlockKey = keyGenerator() + const secondSpanKey = keyGenerator() + const editor = createBareEditor( + [ + { + _type: 'block', + _key: firstBlockKey, + children: [{_type: 'span', _key: firstSpanKey, text: 'foo', marks: []}], + markDefs: [], + style: 'normal', + }, + { + _type: 'block', + _key: secondBlockKey, + children: [ + {_type: 'span', _key: secondSpanKey, text: 'bar', marks: []}, + ], + markDefs: [], + style: 'normal', + }, + ], + keyGenerator, + ) + editor.snapshot.context.value.splice(1, 0, 'oops' as never) + + withoutNormalizing(editor, () => { + inTestChange(editor, () => normalizeNode(editor, ['oops' as never, [1]])) + + expect(editor.dirtyPaths).toEqual([[]]) + }) +}) diff --git a/packages/editor/src/engine/core/normalize-node.ts b/packages/editor/src/engine/core/normalize-node.ts index d9a402c73a..abb527c9ad 100644 --- a/packages/editor/src/engine/core/normalize-node.ts +++ b/packages/editor/src/engine/core/normalize-node.ts @@ -72,7 +72,11 @@ export const normalizeNode: WithEditorFirstArg = ( }) if (structural) { - if (structural.patches.some((patch) => !isAncestorPath(path, patch.path))) { + if ( + structural.patches.some( + (patch) => patch.type !== 'unset' && !isAncestorPath(path, patch.path), + ) + ) { // A fix outside this node's subtree does not dirty this node, so // nothing would visit it again. Queue it below the fix's dirty paths so // the rest of the repairs still run for it. For a duplicate key fix @@ -161,6 +165,13 @@ function applyStructuralCorrection( correction: StructuralCorrection, ): void { for (const patch of correction.patches) { + if (patch.type === 'unset') { + editor.apply({ + type: 'unset', + path: patch.path, + }) + continue + } if (patch.type !== 'set') { throw new Error( `Unsupported ${patch.type} patch in ${correction.type} correction`, @@ -280,6 +291,8 @@ function structuralMessage( spanTypeName: string, ): string { switch (correction.type) { + case 'non-object': + return 'Removing an entry that is not an object' case 'missing-type': return correction.patches.some( (patch) => patch.type === 'set' && patch.value === spanTypeName, diff --git a/packages/editor/src/internal-utils/validateValue.ts b/packages/editor/src/internal-utils/validateValue.ts index a7f1d42c98..e5c0499887 100644 --- a/packages/editor/src/internal-utils/validateValue.ts +++ b/packages/editor/src/internal-utils/validateValue.ts @@ -19,6 +19,7 @@ export function validateValue( value: PortableTextBlock[] | undefined, types: EditorSchema, keyGenerator: () => string, + indexOffset = 0, ): Validation { let resolution: InvalidValueResolution | null = null let valid = true @@ -53,7 +54,8 @@ export function validateValue( } } if ( - value.some((blk: PortableTextBlock, index: number): boolean => { + value.some((blk: PortableTextBlock, blockIndex: number): boolean => { + const index = blockIndex + indexOffset // Is the block an object? if (typeof blk !== 'object' || blk === null) { resolution = { diff --git a/packages/editor/src/internal-utils/values.ts b/packages/editor/src/internal-utils/values.ts index 6e52d8d8fe..20aa3b6aab 100644 --- a/packages/editor/src/internal-utils/values.ts +++ b/packages/editor/src/internal-utils/values.ts @@ -62,12 +62,10 @@ export function toEngineBlock( return {_type, _key, ...rest, children} } - const {children: _originalChildren, ...blockObjectProps} = rest - return { _type, _key, - ...blockObjectProps, + ...rest, } } diff --git a/packages/editor/test-utils/create-bare-editor.test-util.ts b/packages/editor/test-utils/create-bare-editor.test-util.ts new file mode 100644 index 0000000000..49a02cdc43 --- /dev/null +++ b/packages/editor/test-utils/create-bare-editor.test-util.ts @@ -0,0 +1,45 @@ +import { + compileSchema, + defineSchema, + type PortableTextBlock, +} from '@portabletext/schema' +import {createTestKeyGenerator} from '@portabletext/test' +import {createEditor} from '../src/engine/create-editor' +import {buildIndexMaps} from '../src/internal-utils/build-index-maps' +import {resolveContainers} from '../src/schema/resolve-containers-batch' +import type {PortableTextEditorEngine} from '../src/types/editor-engine' + +const schema = compileSchema( + defineSchema({ + annotations: [{name: 'link', fields: [{name: 'href', type: 'string'}]}], + blockObjects: [{name: 'image'}], + decorators: [{name: 'strong'}, {name: 'em'}], + }), +) + +export function createBareEditor( + value: Array, + keyGenerator: () => string = createTestKeyGenerator('generated-'), +): PortableTextEditorEngine { + const editor = createEditor() as PortableTextEditorEngine + const containers = resolveContainers(schema, []) + const blockIndexMap = new Map() + buildIndexMaps({schema, containers, value}, {blockIndexMap}) + editor.containers = new Map() + editor.blockIndexMap = blockIndexMap + editor.verifiedUniqueChildGroups = new Set() + editor.snapshot = { + blockIndexMap, + context: { + containers, + converters: [], + keyGenerator, + readOnly: false, + schema, + selection: null, + value, + }, + decoratorState: {}, + } + return editor +} diff --git a/packages/editor/tests/__fixtures__/wire-catalogue/block-split-multi-span.json b/packages/editor/tests/__fixtures__/wire-catalogue/block-split-multi-span.json new file mode 100644 index 0000000000..5fbc52a146 --- /dev/null +++ b/packages/editor/tests/__fixtures__/wire-catalogue/block-split-multi-span.json @@ -0,0 +1,114 @@ +{ + "scenario": "block-split-multi-span", + "schema": { + "decorators": [ + { + "name": "strong" + } + ] + }, + "seed": "B: fo|o [strong:bar] baz", + "actions": [ + "select {caret at offset 2 on the first span}", + "send {type: 'insert.break'}" + ], + "patches": [ + { + "type": "diffMatchPatch", + "path": [ + { + "_key": "k0" + }, + "children", + { + "_key": "k1" + }, + "text" + ], + "value": "@@ -1,4 +1,2 @@\n fo\n-o \n", + "origin": "local" + }, + { + "type": "unset", + "path": [ + { + "_key": "k0" + }, + "children", + { + "_key": "k2" + } + ], + "origin": "local" + }, + { + "type": "diffMatchPatch", + "path": [ + { + "_key": "k0" + }, + "children", + { + "_key": "k3" + }, + "text" + ], + "value": "@@ -1,4 +0,0 @@\n- baz\n", + "origin": "local" + }, + { + "type": "insert", + "path": [ + { + "_key": "k0" + } + ], + "position": "after", + "items": [ + { + "_type": "block", + "_key": "k6", + "children": [ + { + "_type": "span", + "_key": "k1", + "text": "o ", + "marks": [] + }, + { + "_type": "span", + "_key": "k2", + "text": "bar", + "marks": [ + "strong" + ] + }, + { + "_type": "span", + "_key": "k3", + "text": " baz", + "marks": [] + } + ], + "markDefs": [], + "style": "normal" + } + ], + "origin": "local" + }, + { + "type": "unset", + "path": [ + { + "_key": "k0" + }, + "children", + { + "_key": "k3" + } + ], + "origin": "local" + } + ], + "result": "B: fo\nB: |o [strong:bar] baz" +} diff --git a/packages/editor/tests/backspace-before-container.test.tsx b/packages/editor/tests/backspace-before-container.test.tsx index bdfaa13123..ab72063150 100644 --- a/packages/editor/tests/backspace-before-container.test.tsx +++ b/packages/editor/tests/backspace-before-container.test.tsx @@ -81,7 +81,7 @@ const containers = [ describe('Backspace before container', () => { test('Scenario: Pressing Backspace at the start of an empty text block after a callout lands at the end of the callout content', async () => { const keyGenerator = createTestKeyGenerator() - const {editor, locator} = await createTestEditor({ + const {editor} = await createTestEditor({ keyGenerator, schemaDefinition, initialValue: [ @@ -105,8 +105,6 @@ describe('Backspace before container', () => { children: , }) - await expect.element(locator).toBeInTheDocument() - editor.send({ type: 'select', at: { @@ -133,7 +131,7 @@ describe('Backspace before container', () => { test('Scenario: Pressing Backspace at the start of an empty text block after a callout with multi-block content lands at the end of the last block', async () => { const keyGenerator = createTestKeyGenerator() - const {editor, locator} = await createTestEditor({ + const {editor} = await createTestEditor({ keyGenerator, schemaDefinition, initialValue: [ @@ -162,8 +160,6 @@ describe('Backspace before container', () => { children: , }) - await expect.element(locator).toBeInTheDocument() - editor.send({ type: 'select', at: { @@ -190,7 +186,7 @@ describe('Backspace before container', () => { test('Scenario: Pressing Backspace at the start of an empty text block after a table lands at the end of the last cell content', async () => { const keyGenerator = createTestKeyGenerator() - const {editor, locator} = await createTestEditor({ + const {editor} = await createTestEditor({ keyGenerator, schemaDefinition, initialValue: [ @@ -241,8 +237,6 @@ describe('Backspace before container', () => { children: , }) - await expect.element(locator).toBeInTheDocument() - editor.send({ type: 'select', at: { @@ -276,7 +270,7 @@ describe('Backspace before container', () => { test('Scenario: Pressing Backspace at the start of an empty text block after a void block-object focuses the block-object', async () => { const keyGenerator = createTestKeyGenerator() - const {editor, locator} = await createTestEditor({ + const {editor} = await createTestEditor({ keyGenerator, schemaDefinition, initialValue: [ @@ -290,8 +284,6 @@ describe('Backspace before container', () => { children: , }) - await expect.element(locator).toBeInTheDocument() - editor.send({ type: 'select', at: { @@ -316,7 +308,7 @@ describe('Backspace before container', () => { test('Scenario: Pressing Backspace at the start of an empty text block after a table whose last cell ends in an image focuses the image', async () => { const keyGenerator = createTestKeyGenerator() - const {editor, locator} = await createTestEditor({ + const {editor} = await createTestEditor({ keyGenerator, schemaDefinition, initialValue: [ @@ -359,8 +351,6 @@ describe('Backspace before container', () => { children: , }) - await expect.element(locator).toBeInTheDocument() - editor.send({ type: 'select', at: { diff --git a/packages/editor/tests/container-edge-escape.test.tsx b/packages/editor/tests/container-edge-escape.test.tsx index 773439041d..2ad4b02fce 100644 --- a/packages/editor/tests/container-edge-escape.test.tsx +++ b/packages/editor/tests/container-edge-escape.test.tsx @@ -62,13 +62,12 @@ const arrowKey = (key: 'ArrowDown' | 'ArrowUp') => ({ }) async function createLonelyContainerEditor() { - const {editor, locator} = await createTestEditor({ + const {editor} = await createTestEditor({ keyGenerator: createTestKeyGenerator(), schemaDefinition, initialValue: [calloutBlock], children: , }) - await expect.element(locator).toBeInTheDocument() editor.send({type: 'focus'}) return editor } @@ -261,7 +260,7 @@ describe('escaping a container at the document edge', () => { }) test('Scenario: Clicking below a lonely container nested in a container inserts a block inside the outer one', async () => { - const {editor, locator} = await createTestEditor({ + const {editor} = await createTestEditor({ keyGenerator: createTestKeyGenerator(), schemaDefinition: defineSchema({ blockObjects: [ @@ -313,7 +312,6 @@ describe('escaping a container at the document edge', () => { /> ), }) - await expect.element(locator).toBeInTheDocument() editor.send({type: 'focus'}) const innerLeaf = { path: [ @@ -366,8 +364,7 @@ describe('escaping a container at the document edge', () => { // Three levels deep (table -> row -> cell shaped), the first cell of a // non-first row is the *nearest* container's start edge but not the // outer container's: native navigation stays inside. - const {editor, locator} = await createGridEditor() - await expect.element(locator).toBeInTheDocument() + const {editor} = await createGridEditor() editor.send({type: 'focus'}) // The second row's cell: its block is the nearest container's edge @@ -410,8 +407,7 @@ describe('escaping a container at the document edge', () => { // placeholder inside it and must leave the value alone. The editor // surface beyond the lonely table is a different place: the root // accepts text blocks, so that click still escapes. - const {editor, locator} = await createGridEditor() - await expect.element(locator).toBeInTheDocument() + const {editor} = await createGridEditor() editor.send({type: 'focus'}) const gridSurfacePoint = {path: [{_key: 'g0'}], offset: 0} @@ -505,7 +501,7 @@ describe('escaping a container at the document edge', () => { }) test('Scenario: Pressing ArrowDown inserts nothing when the container has a next sibling', async () => { - const {editor, locator} = await createTestEditor({ + const {editor} = await createTestEditor({ keyGenerator: createTestKeyGenerator(), schemaDefinition, initialValue: [ @@ -520,7 +516,6 @@ describe('escaping a container at the document edge', () => { ], children: , }) - await expect.element(locator).toBeInTheDocument() editor.send({type: 'focus'}) editor.send({ type: 'select', diff --git a/packages/editor/tests/container-insert-block.test.tsx b/packages/editor/tests/container-insert-block.test.tsx index aea3845a7e..0b466e5bcd 100644 --- a/packages/editor/tests/container-insert-block.test.tsx +++ b/packages/editor/tests/container-insert-block.test.tsx @@ -1,6 +1,8 @@ +import {applyAll, type Patch} from '@portabletext/patches' import {defineSchema} from '@portabletext/schema' import {createTestKeyGenerator} from '@portabletext/test' import {describe, expect, test, vi} from 'vitest' +import {EventListenerPlugin} from '../src/plugins/plugin.event-listener' import {NodePlugin} from '../src/plugins/plugin.node' import {defineContainer} from '../src/renderers/renderer.types' import {createTestEditor} from '../src/test/vitest' @@ -28,6 +30,58 @@ const calloutContainer = [ }), ] +const childrenFieldSchemaDefinition = defineSchema({ + blockObjects: [ + { + name: 'gallery', + fields: [ + {name: 'title', type: 'string'}, + { + name: 'children', + type: 'array', + of: [ + { + type: 'object', + name: 'item', + fields: [{name: 'label', type: 'string'}], + }, + ], + }, + ], + }, + { + name: 'callout', + fields: [ + {name: 'title', type: 'string'}, + {name: 'children', type: 'array', of: [{type: 'block'}]}, + ], + }, + { + name: 'section', + fields: [ + { + name: 'content', + type: 'array', + of: [{type: 'block'}, {type: 'callout'}, {type: 'gallery'}], + }, + ], + }, + ], +}) + +const childrenFieldNodes = [ + defineContainer({ + type: 'callout', + arrayField: 'children', + render: ({attributes, children}) =>
{children}
, + }), + defineContainer({ + type: 'section', + arrayField: 'content', + render: ({attributes, children}) =>
{children}
, + }), +] + describe('insert.block inside a container', () => { test('Scenario: Inserting a block with placement after adds a sibling after the focused block inside the container', async () => { const keyGenerator = createTestKeyGenerator() @@ -503,4 +557,313 @@ describe('insert.block inside a container', () => { ]) }) }) + + test('Scenario: Inserting a gallery inside a section keeps its children', async () => { + const keyGenerator = createTestKeyGenerator() + const fooKey = keyGenerator() + const fooSpanKey = keyGenerator() + const galleryKey = keyGenerator() + const itemKey = keyGenerator() + const barKey = keyGenerator() + const barSpanKey = keyGenerator() + + const sectionKey = keyGenerator() + const nestedPath = [ + {_key: sectionKey}, + 'content', + {_key: barKey}, + 'children', + {_key: barSpanKey}, + ] + + const initialValue = [ + section(sectionKey, [ + textBlock(fooKey, fooSpanKey, 'foo'), + textBlock(barKey, barSpanKey, 'bar'), + ]), + ] + const patches: Array = [] + const {editor} = await createTestEditor({ + keyGenerator, + schemaDefinition: childrenFieldSchemaDefinition, + initialValue, + children: ( + <> + + { + if (event.type === 'patch') { + patches.push(event.patch) + } + }} + /> + + ), + }) + + editor.send({ + type: 'select', + at: { + anchor: {path: nestedPath, offset: 3}, + focus: {path: nestedPath, offset: 3}, + }, + }) + editor.send({ + type: 'insert.block', + block: gallery(galleryKey, itemKey, 'a'), + placement: 'after', + }) + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value).toEqual([ + { + _type: 'section', + _key: sectionKey, + content: [ + { + _type: 'block', + _key: fooKey, + children: [ + {_type: 'span', _key: fooSpanKey, text: 'foo', marks: []}, + ], + markDefs: [], + style: 'normal', + }, + { + _type: 'block', + _key: barKey, + children: [ + {_type: 'span', _key: barSpanKey, text: 'bar', marks: []}, + ], + markDefs: [], + style: 'normal', + }, + { + _type: 'gallery', + _key: galleryKey, + title: 'T', + children: [{_type: 'item', _key: itemKey, label: 'a'}], + }, + ], + }, + ]) + expect(applyAll(initialValue, patches)).toEqual([ + { + _type: 'section', + _key: sectionKey, + content: [ + { + _type: 'block', + _key: fooKey, + children: [ + {_type: 'span', _key: fooSpanKey, text: 'foo', marks: []}, + ], + markDefs: [], + style: 'normal', + }, + { + _type: 'block', + _key: barKey, + children: [ + {_type: 'span', _key: barSpanKey, text: 'bar', marks: []}, + ], + markDefs: [], + style: 'normal', + }, + { + _type: 'gallery', + _key: galleryKey, + title: 'T', + children: [{_type: 'item', _key: itemKey, label: 'a'}], + }, + ], + }, + ]) + }) + }) + + test('Scenario: Inserting a callout inside a section keeps its children', async () => { + const keyGenerator = createTestKeyGenerator() + const fooKey = keyGenerator() + const fooSpanKey = keyGenerator() + + const barKey = keyGenerator() + const barSpanKey = keyGenerator() + const calloutKey = keyGenerator() + const innerBlockKey = keyGenerator() + const innerSpanKey = keyGenerator() + const sectionKey = keyGenerator() + const nestedPath = [ + {_key: sectionKey}, + 'content', + {_key: barKey}, + 'children', + {_key: barSpanKey}, + ] + + const initialValue = [ + section(sectionKey, [ + textBlock(fooKey, fooSpanKey, 'foo'), + textBlock(barKey, barSpanKey, 'bar'), + ]), + ] + const patches: Array = [] + const {editor, locator} = await createTestEditor({ + keyGenerator, + schemaDefinition: childrenFieldSchemaDefinition, + initialValue, + children: ( + <> + + { + if (event.type === 'patch') { + patches.push(event.patch) + } + }} + /> + + ), + }) + + editor.send({ + type: 'select', + at: { + anchor: {path: nestedPath, offset: 3}, + focus: {path: nestedPath, offset: 3}, + }, + }) + editor.send({ + type: 'insert.block', + block: callout(calloutKey, innerBlockKey, innerSpanKey, 'new'), + placement: 'after', + }) + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value).toEqual([ + { + _type: 'section', + _key: sectionKey, + content: [ + { + _type: 'block', + _key: fooKey, + children: [ + {_type: 'span', _key: fooSpanKey, text: 'foo', marks: []}, + ], + markDefs: [], + style: 'normal', + }, + { + _type: 'block', + _key: barKey, + children: [ + {_type: 'span', _key: barSpanKey, text: 'bar', marks: []}, + ], + markDefs: [], + style: 'normal', + }, + { + _type: 'callout', + _key: calloutKey, + title: 'T', + children: [ + { + _type: 'block', + _key: innerBlockKey, + children: [ + {_type: 'span', _key: innerSpanKey, text: 'new', marks: []}, + ], + markDefs: [], + style: 'normal', + }, + ], + }, + ], + }, + ]) + expect(applyAll(initialValue, patches)).toEqual([ + { + _type: 'section', + _key: sectionKey, + content: [ + { + _type: 'block', + _key: fooKey, + children: [ + {_type: 'span', _key: fooSpanKey, text: 'foo', marks: []}, + ], + markDefs: [], + style: 'normal', + }, + { + _type: 'block', + _key: barKey, + children: [ + {_type: 'span', _key: barSpanKey, text: 'bar', marks: []}, + ], + markDefs: [], + style: 'normal', + }, + { + _type: 'callout', + _key: calloutKey, + title: 'T', + children: [ + { + _type: 'block', + _key: innerBlockKey, + children: [ + {_type: 'span', _key: innerSpanKey, text: 'new', marks: []}, + ], + markDefs: [], + style: 'normal', + }, + ], + }, + ], + }, + ]) + }) + await vi.waitFor(() => { + expect(locator.element().textContent).toEqual('foobarnew') + }) + }) }) + +function textBlock(blockKey: string, spanKey: string, text: string) { + return { + _type: 'block', + _key: blockKey, + children: [{_type: 'span', _key: spanKey, text, marks: []}], + markDefs: [], + style: 'normal', + } +} + +function gallery(key: string, itemKey: string, label: string) { + return { + _type: 'gallery', + _key: key, + title: 'T', + children: [{_type: 'item', _key: itemKey, label}], + } +} + +function callout(key: string, blockKey: string, spanKey: string, text: string) { + return { + _type: 'callout', + _key: key, + title: 'T', + children: [textBlock(blockKey, spanKey, text)], + } +} + +function section( + key: string, + content: Array< + | ReturnType + | ReturnType + | ReturnType + >, +) { + return {_type: 'section', _key: key, content} +} diff --git a/packages/editor/tests/container-rendering.test.tsx b/packages/editor/tests/container-rendering.test.tsx index 5d8350434c..b98073ea02 100644 --- a/packages/editor/tests/container-rendering.test.tsx +++ b/packages/editor/tests/container-rendering.test.tsx @@ -1,7 +1,9 @@ +import type {Patch} from '@portabletext/patches' import {defineSchema} from '@portabletext/schema' import {createTestKeyGenerator} from '@portabletext/test' import type {ReactElement} from 'react' import {describe, expect, test, vi} from 'vitest' +import {EventListenerPlugin} from '../src/plugins/plugin.event-listener' import {NodePlugin} from '../src/plugins/plugin.node' import {defineContainer, defineTextBlock} from '../src/renderers/renderer.types' import {createTestEditor} from '../src/test/vitest' @@ -41,6 +43,26 @@ const calloutContainer = defineContainer({ ), }) +const childrenFieldSchemaDefinition = defineSchema({ + blockObjects: [ + { + name: 'callout', + fields: [ + {name: 'title', type: 'string'}, + {name: 'children', type: 'array', of: [{type: 'block'}]}, + ], + }, + ], +}) + +const childrenFieldNodes = [ + defineContainer({ + type: 'callout', + arrayField: 'children', + render: ({attributes, children}) =>
{children}
, + }), +] + describe('container rendering', () => { test('Scenario: A callout renders with the correct DOM structure', async () => { const keyGenerator = createTestKeyGenerator() @@ -187,6 +209,101 @@ describe('container rendering', () => { }) }) }) + + test('Scenario: Loading a children container preserves its content without patches', async () => { + const keyGenerator = createTestKeyGenerator() + const fooKey = keyGenerator() + const fooSpanKey = keyGenerator() + + const barKey = keyGenerator() + const barSpanKey = keyGenerator() + const calloutKey = keyGenerator() + const innerBlockKey = keyGenerator() + const innerSpanKey = keyGenerator() + + const textPath1 = [{_key: fooKey}, 'children', {_key: fooSpanKey}] + + const initialValue = [ + textBlock(fooKey, fooSpanKey, 'foo'), + callout(calloutKey, innerBlockKey, innerSpanKey, 'inside'), + textBlock(barKey, barSpanKey, 'bar'), + ] + const patches: Array = [] + const {editor, locator} = await createTestEditor({ + keyGenerator, + schemaDefinition: childrenFieldSchemaDefinition, + initialValue, + children: ( + <> + + { + if (event.type === 'patch') { + patches.push(event.patch) + } + }} + /> + + ), + }) + + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value).toEqual([ + { + _type: 'block', + _key: fooKey, + children: [{_type: 'span', _key: fooSpanKey, text: 'foo', marks: []}], + markDefs: [], + style: 'normal', + }, + { + _type: 'callout', + _key: calloutKey, + title: 'T', + children: [ + { + _type: 'block', + _key: innerBlockKey, + children: [ + {_type: 'span', _key: innerSpanKey, text: 'inside', marks: []}, + ], + markDefs: [], + style: 'normal', + }, + ], + }, + { + _type: 'block', + _key: barKey, + children: [{_type: 'span', _key: barSpanKey, text: 'bar', marks: []}], + markDefs: [], + style: 'normal', + }, + ]) + expect(patches).toEqual([]) + }) + await vi.waitFor(() => { + expect(locator.element().textContent).toEqual('fooinsidebar') + }) + editor.send({ + type: 'select', + at: { + anchor: {path: textPath1, offset: 3}, + focus: {path: textPath1, offset: 3}, + }, + }) + editor.send({type: 'insert.text', text: 'x'}) + await vi.waitFor(() => { + expect(patches).toEqual([ + { + type: 'diffMatchPatch', + origin: 'local', + path: [{_key: fooKey}, 'children', {_key: fooSpanKey}, 'text'], + value: '@@ -1,3 +1,4 @@\n foo\n+x\n', + }, + ]) + }) + }) }) describe('table with nested rows and cells', () => { @@ -1180,3 +1297,22 @@ describe('self-referential containers', () => { }) }) }) + +function textBlock(blockKey: string, spanKey: string, text: string) { + return { + _type: 'block', + _key: blockKey, + children: [{_type: 'span', _key: spanKey, text, marks: []}], + markDefs: [], + style: 'normal', + } +} + +function callout(key: string, blockKey: string, spanKey: string, text: string) { + return { + _type: 'callout', + _key: key, + title: 'T', + children: [textBlock(blockKey, spanKey, text)], + } +} diff --git a/packages/editor/tests/container-typing.test.tsx b/packages/editor/tests/container-typing.test.tsx index 54ac3059a9..af21f45681 100644 --- a/packages/editor/tests/container-typing.test.tsx +++ b/packages/editor/tests/container-typing.test.tsx @@ -1,7 +1,9 @@ +import {applyAll, type Patch} from '@portabletext/patches' import {defineSchema} from '@portabletext/schema' import {createTestKeyGenerator} from '@portabletext/test' import {describe, expect, test, vi} from 'vitest' import {userEvent} from 'vitest/browser' +import {EventListenerPlugin} from '../src/plugins/plugin.event-listener' import {NodePlugin} from '../src/plugins/plugin.node' import {defineContainer} from '../src/renderers/renderer.types' import {createTestEditor} from '../src/test/vitest' @@ -31,6 +33,26 @@ const calloutContainer = defineContainer({ ), }) +const childrenFieldSchemaDefinition = defineSchema({ + blockObjects: [ + { + name: 'callout', + fields: [ + {name: 'title', type: 'string'}, + {name: 'children', type: 'array', of: [{type: 'block'}]}, + ], + }, + ], +}) + +const childrenFieldNodes = [ + defineContainer({ + type: 'callout', + arrayField: 'children', + render: ({attributes, children}) =>
{children}
, + }), +] + describe('typing inside containers', () => { test('Scenario: Multi-character keyboard input inside a container lands in the container', async () => { const keyGenerator = createTestKeyGenerator() @@ -100,4 +122,129 @@ describe('typing inside containers', () => { ]) }) }) + + test('Scenario: Typing in a children container emits only its nested text diff', async () => { + const keyGenerator = createTestKeyGenerator() + const fooKey = keyGenerator() + const fooSpanKey = keyGenerator() + + const barKey = keyGenerator() + const barSpanKey = keyGenerator() + const calloutKey = keyGenerator() + const innerBlockKey = keyGenerator() + const innerSpanKey = keyGenerator() + + const nestedPath1 = [ + {_key: calloutKey}, + 'children', + {_key: innerBlockKey}, + 'children', + {_key: innerSpanKey}, + ] + + const initialValue = [ + textBlock(fooKey, fooSpanKey, 'foo'), + callout(calloutKey, innerBlockKey, innerSpanKey, 'inside'), + textBlock(barKey, barSpanKey, 'bar'), + ] + const patches: Array = [] + const {editor, locator} = await createTestEditor({ + keyGenerator, + schemaDefinition: childrenFieldSchemaDefinition, + initialValue, + children: ( + <> + + { + if (event.type === 'patch') { + patches.push(event.patch) + } + }} + /> + + ), + }) + + await vi.waitFor(() => { + expect(locator.element().textContent).toEqual('fooinsidebar') + }) + await userEvent.click(locator) + editor.send({ + type: 'select', + at: { + anchor: {path: nestedPath1, offset: 3}, + focus: {path: nestedPath1, offset: 3}, + }, + }) + await userEvent.keyboard('x') + await vi.waitFor(() => { + expect(applyAll(initialValue, patches)).toEqual([ + { + _type: 'block', + _key: fooKey, + children: [{_type: 'span', _key: fooSpanKey, text: 'foo', marks: []}], + markDefs: [], + style: 'normal', + }, + { + _type: 'callout', + _key: calloutKey, + title: 'T', + children: [ + { + _type: 'block', + _key: innerBlockKey, + children: [ + {_type: 'span', _key: innerSpanKey, text: 'insxide', marks: []}, + ], + markDefs: [], + style: 'normal', + }, + ], + }, + { + _type: 'block', + _key: barKey, + children: [{_type: 'span', _key: barSpanKey, text: 'bar', marks: []}], + markDefs: [], + style: 'normal', + }, + ]) + expect(patches).toEqual([ + { + type: 'diffMatchPatch', + origin: 'local', + path: [ + {_key: calloutKey}, + 'children', + {_key: innerBlockKey}, + 'children', + {_key: innerSpanKey}, + 'text', + ], + value: '@@ -1,6 +1,7 @@\n ins\n+x\n ide\n', + }, + ]) + }) + }) }) + +function textBlock(blockKey: string, spanKey: string, text: string) { + return { + _type: 'block', + _key: blockKey, + children: [{_type: 'span', _key: spanKey, text, marks: []}], + markDefs: [], + style: 'normal', + } +} + +function callout(key: string, blockKey: string, spanKey: string, text: string) { + return { + _type: 'callout', + _key: key, + title: 'T', + children: [textBlock(blockKey, spanKey, text)], + } +} diff --git a/packages/editor/tests/delete-empty-container.test.tsx b/packages/editor/tests/delete-empty-container.test.tsx index 1dda650f50..b7c4270fb3 100644 --- a/packages/editor/tests/delete-empty-container.test.tsx +++ b/packages/editor/tests/delete-empty-container.test.tsx @@ -30,7 +30,7 @@ const containers = [ describe('delete on empty container', () => { test('Scenario: Pressing Backspace in an empty callout with a text block before unwraps it to root', async () => { const keyGenerator = createTestKeyGenerator() - const {editor, locator} = await createTestEditor({ + const {editor} = await createTestEditor({ keyGenerator, schemaDefinition, initialValue: [ @@ -54,8 +54,6 @@ describe('delete on empty container', () => { children: , }) - await expect.element(locator).toBeInTheDocument() - editor.send({ type: 'select', at: { @@ -91,7 +89,7 @@ describe('delete on empty container', () => { test('Scenario: Pressing Backspace in an empty callout with a text block after unwraps it to root', async () => { const keyGenerator = createTestKeyGenerator() - const {editor, locator} = await createTestEditor({ + const {editor} = await createTestEditor({ keyGenerator, schemaDefinition, initialValue: [ @@ -115,8 +113,6 @@ describe('delete on empty container', () => { children: , }) - await expect.element(locator).toBeInTheDocument() - editor.send({ type: 'select', at: { @@ -152,7 +148,7 @@ describe('delete on empty container', () => { test('Scenario: Pressing Backspace in an empty callout that is the only block leaves a placeholder text block', async () => { const keyGenerator = createTestKeyGenerator() - const {editor, locator} = await createTestEditor({ + const {editor} = await createTestEditor({ keyGenerator, schemaDefinition, initialValue: [ @@ -171,8 +167,6 @@ describe('delete on empty container', () => { children: , }) - await expect.element(locator).toBeInTheDocument() - editor.send({ type: 'select', at: { @@ -208,7 +202,7 @@ describe('delete on empty container', () => { test('Scenario: Pressing Delete in an empty callout with a text block after unwraps it to root', async () => { const keyGenerator = createTestKeyGenerator() - const {editor, locator} = await createTestEditor({ + const {editor} = await createTestEditor({ keyGenerator, schemaDefinition, initialValue: [ @@ -232,8 +226,6 @@ describe('delete on empty container', () => { children: , }) - await expect.element(locator).toBeInTheDocument() - editor.send({ type: 'select', at: { @@ -443,7 +435,7 @@ const calloutInPermissiveCellSelection = { describe('delete on empty container - nested cascade', () => { test('Scenario: Pressing Backspace in an empty callout in a permissive cell unwraps only the callout', async () => { const keyGenerator = createTestKeyGenerator() - const {editor, locator} = await createTestEditor({ + const {editor} = await createTestEditor({ keyGenerator, schemaDefinition: tableSchemaPermissive, initialValue: [ @@ -482,8 +474,6 @@ describe('delete on empty container - nested cascade', () => { children: , }) - await expect.element(locator).toBeInTheDocument() - editor.send({ type: 'select', at: { @@ -533,7 +523,7 @@ describe('delete on empty container - nested cascade', () => { test('Scenario: Pressing Backspace in an empty callout in a lonely structural cell cascades all the way to root', async () => { const keyGenerator = createTestKeyGenerator() - const {editor, locator} = await createTestEditor({ + const {editor} = await createTestEditor({ keyGenerator, schemaDefinition: tableSchemaStructural, initialValue: [ @@ -572,8 +562,6 @@ describe('delete on empty container - nested cascade', () => { children: , }) - await expect.element(locator).toBeInTheDocument() - editor.send({ type: 'select', at: { @@ -595,7 +583,7 @@ describe('delete on empty container - nested cascade', () => { test('Scenario: Pressing Backspace in an empty callout in a lonely structural cell with a text block before lands the payload after that text block', async () => { const keyGenerator = createTestKeyGenerator() - const {editor, locator} = await createTestEditor({ + const {editor} = await createTestEditor({ keyGenerator, schemaDefinition: tableSchemaStructural, initialValue: [ @@ -639,8 +627,6 @@ describe('delete on empty container - nested cascade', () => { children: , }) - await expect.element(locator).toBeInTheDocument() - editor.send({ type: 'select', at: { @@ -660,7 +646,7 @@ describe('delete on empty container - nested cascade', () => { test('Scenario: Pressing Backspace in an empty callout in a structural cell with sibling cells changes nothing', async () => { const keyGenerator = createTestKeyGenerator() - const {editor, locator} = await createTestEditor({ + const {editor} = await createTestEditor({ keyGenerator, schemaDefinition: tableSchemaStructural, initialValue: [ @@ -723,8 +709,6 @@ describe('delete on empty container - nested cascade', () => { children: , }) - await expect.element(locator).toBeInTheDocument() - const before = editor.getSnapshot().context.value editor.send({ @@ -800,7 +784,7 @@ describe('delete on empty container - nested block container', () => { test('Scenario: Pressing Backspace inside an empty fact-box with a nested block container unwraps the fact-box', async () => { const keyGenerator = createTestKeyGenerator() - const {editor, locator} = await createTestEditor({ + const {editor} = await createTestEditor({ keyGenerator, schemaDefinition, initialValue: [ @@ -821,8 +805,6 @@ describe('delete on empty container - nested block container', () => { children: , }) - await expect.element(locator).toBeInTheDocument() - editor.send({ type: 'select', at: { @@ -858,7 +840,7 @@ describe('delete on empty container - nested block container', () => { test('Scenario: Pressing Backspace at the start of a non-first text block inside a fact-box merges it with the previous block', async () => { const keyGenerator = createTestKeyGenerator() - const {editor, locator} = await createTestEditor({ + const {editor} = await createTestEditor({ keyGenerator, schemaDefinition, initialValue: [ @@ -886,8 +868,6 @@ describe('delete on empty container - nested block container', () => { children: , }) - await expect.element(locator).toBeInTheDocument() - editor.send({ type: 'select', at: { diff --git a/packages/editor/tests/event.delete.matrix.test.tsx b/packages/editor/tests/event.delete.matrix.test.tsx index 7f93abf21f..fa173ebaf8 100644 --- a/packages/editor/tests/event.delete.matrix.test.tsx +++ b/packages/editor/tests/event.delete.matrix.test.tsx @@ -356,14 +356,12 @@ describe('event.delete | unified primitives matrix', () => { async function setup(value: Array) { const keyGenerator = createTestKeyGenerator() - const {editor, locator} = await createTestEditor({ + const {editor} = await createTestEditor({ keyGenerator, schemaDefinition, initialValue: value as never, }) - await expect.element(locator).toBeInTheDocument() - return editor } diff --git a/packages/editor/tests/event.delete.test.tsx b/packages/editor/tests/event.delete.test.tsx index 5731f31f51..bacbe84d4a 100644 --- a/packages/editor/tests/event.delete.test.tsx +++ b/packages/editor/tests/event.delete.test.tsx @@ -18,7 +18,7 @@ import { describe('event.delete', () => { test('Scenario: Deleting collapsed selection', async () => { const keyGenerator = createTestKeyGenerator() - const {editor, locator} = await createTestEditor({ + const {editor} = await createTestEditor({ keyGenerator, schemaDefinition: defineSchema({ blockObjects: [{name: 'image'}], @@ -53,8 +53,6 @@ describe('event.delete', () => { ], }) - await expect.element(locator).toBeInTheDocument() - await vi.waitFor(() => { expect(toTextspec(editor.getSnapshot().context)).toEqual( ['B: foo', 'B: bar', '{IMAGE}'].join('\n'), @@ -1283,7 +1281,7 @@ describe('event.delete', () => { test('Scenario: Deleting a range across two empty same-parent blocks', async () => { const keyGenerator = createTestKeyGenerator() - const {editor, locator} = await createTestEditor({ + const {editor} = await createTestEditor({ keyGenerator, schemaDefinition: defineSchema({ styles: [{name: 'normal'}, {name: 'h1'}], @@ -1306,8 +1304,6 @@ describe('event.delete', () => { ], }) - await expect.element(locator).toBeInTheDocument() - editor.send({ type: 'delete', at: { @@ -1323,7 +1319,7 @@ describe('event.delete', () => { test('Scenario: Deleting a range across three empty same-parent blocks', async () => { const keyGenerator = createTestKeyGenerator() - const {editor, locator} = await createTestEditor({ + const {editor} = await createTestEditor({ keyGenerator, schemaDefinition: defineSchema({ styles: [{name: 'normal'}, {name: 'h1'}], @@ -1353,8 +1349,6 @@ describe('event.delete', () => { ], }) - await expect.element(locator).toBeInTheDocument() - editor.send({ type: 'delete', at: { diff --git a/packages/editor/tests/event.history.undo.test.tsx b/packages/editor/tests/event.history.undo.test.tsx index 83a081030e..2f81a33a3c 100644 --- a/packages/editor/tests/event.history.undo.test.tsx +++ b/packages/editor/tests/event.history.undo.test.tsx @@ -1,3 +1,4 @@ +import {applyAll, type Patch} from '@portabletext/patches' import {defineSchema} from '@portabletext/schema' import {createTestKeyGenerator, toTextspec} from '@portabletext/test' import {describe, expect, test, vi} from 'vitest' @@ -11,10 +12,33 @@ import { import {defineBehavior} from '../src/behaviors/behavior.types.behavior' import {IS_MAC} from '../src/internal-utils/is-hotkey' import {BehaviorPlugin} from '../src/plugins/plugin.behavior' +import {EventListenerPlugin} from '../src/plugins/plugin.event-listener' import {getFirstBlock, getFocusBlock} from '../src/selectors' import {createTestEditor} from '../src/test/vitest' import type {EditorSelection} from '../src/types/editor' +const childrenFieldSchemaDefinition = defineSchema({ + blockObjects: [ + { + name: 'gallery', + fields: [ + {name: 'title', type: 'string'}, + { + name: 'children', + type: 'array', + of: [ + { + type: 'object', + name: 'item', + fields: [{name: 'label', type: 'string'}], + }, + ], + }, + ], + }, + ], +}) + describe('event.history.undo', () => { test('Scenario: Undoing action sets', async () => { const {editor, locator} = await createTestEditor({ @@ -1176,4 +1200,137 @@ describe('event.history.undo', () => { expect(editor.getSnapshot().context.value).toEqual(repairedValue) }) }) + + test('Scenario: Undoing gallery deletion restores its children', async () => { + const keyGenerator = createTestKeyGenerator() + const fooKey = keyGenerator() + const fooSpanKey = keyGenerator() + const galleryKey = keyGenerator() + const itemKey = keyGenerator() + const barKey = keyGenerator() + const barSpanKey = keyGenerator() + + const initialValue = [ + textBlock(fooKey, fooSpanKey, 'foo'), + gallery(galleryKey, itemKey, 'a'), + textBlock(barKey, barSpanKey, 'bar'), + ] + const patches: Array = [] + const {editor} = await createTestEditor({ + keyGenerator, + schemaDefinition: childrenFieldSchemaDefinition, + initialValue, + children: ( + { + if (event.type === 'patch') { + patches.push(event.patch) + } + }} + /> + ), + }) + + editor.send({type: 'delete.block', at: [{_key: galleryKey}]}) + await vi.waitFor(() => { + expect(applyAll(initialValue, patches)).toEqual([ + { + _type: 'block', + _key: fooKey, + children: [{_type: 'span', _key: fooSpanKey, text: 'foo', marks: []}], + markDefs: [], + style: 'normal', + }, + { + _type: 'block', + _key: barKey, + children: [{_type: 'span', _key: barSpanKey, text: 'bar', marks: []}], + markDefs: [], + style: 'normal', + }, + ]) + expect(editor.getSnapshot().context.value).toEqual([ + { + _type: 'block', + _key: fooKey, + children: [{_type: 'span', _key: fooSpanKey, text: 'foo', marks: []}], + markDefs: [], + style: 'normal', + }, + { + _type: 'block', + _key: barKey, + children: [{_type: 'span', _key: barSpanKey, text: 'bar', marks: []}], + markDefs: [], + style: 'normal', + }, + ]) + }) + editor.send({type: 'history.undo'}) + await vi.waitFor(() => { + expect(applyAll(initialValue, patches)).toEqual([ + { + _type: 'block', + _key: fooKey, + children: [{_type: 'span', _key: fooSpanKey, text: 'foo', marks: []}], + markDefs: [], + style: 'normal', + }, + { + _type: 'gallery', + _key: galleryKey, + title: 'T', + children: [{_type: 'item', _key: itemKey, label: 'a'}], + }, + { + _type: 'block', + _key: barKey, + children: [{_type: 'span', _key: barSpanKey, text: 'bar', marks: []}], + markDefs: [], + style: 'normal', + }, + ]) + expect(editor.getSnapshot().context.value).toEqual([ + { + _type: 'block', + _key: fooKey, + children: [{_type: 'span', _key: fooSpanKey, text: 'foo', marks: []}], + markDefs: [], + style: 'normal', + }, + { + _type: 'gallery', + _key: galleryKey, + title: 'T', + children: [{_type: 'item', _key: itemKey, label: 'a'}], + }, + { + _type: 'block', + _key: barKey, + children: [{_type: 'span', _key: barSpanKey, text: 'bar', marks: []}], + markDefs: [], + style: 'normal', + }, + ]) + }) + }) }) + +function textBlock(blockKey: string, spanKey: string, text: string) { + return { + _type: 'block', + _key: blockKey, + children: [{_type: 'span', _key: spanKey, text, marks: []}], + markDefs: [], + style: 'normal', + } +} + +function gallery(key: string, itemKey: string, label: string) { + return { + _type: 'gallery', + _key: key, + title: 'T', + children: [{_type: 'item', _key: itemKey, label}], + } +} diff --git a/packages/editor/tests/event.insert.block.test.tsx b/packages/editor/tests/event.insert.block.test.tsx index 42973545a8..cf4794f4de 100644 --- a/packages/editor/tests/event.insert.block.test.tsx +++ b/packages/editor/tests/event.insert.block.test.tsx @@ -1,4 +1,4 @@ -import type {Patch} from '@portabletext/patches' +import {applyAll, type Patch} from '@portabletext/patches' import {defineSchema} from '@portabletext/schema' import {createTestKeyGenerator, toTextspec} from '@portabletext/test' import {describe, expect, test, vi} from 'vitest' @@ -12,6 +12,43 @@ import {defineContainer} from '../src/renderers/renderer.types' import {getFocusBlock} from '../src/selectors/selector.get-focus-block' import {createTestEditor} from '../src/test/vitest' +const childrenFieldSchemaDefinition = defineSchema({ + blockObjects: [ + { + name: 'callout', + fields: [ + {name: 'title', type: 'string'}, + {name: 'children', type: 'array', of: [{type: 'block'}]}, + ], + }, + { + name: 'gallery', + fields: [ + {name: 'title', type: 'string'}, + { + name: 'children', + type: 'array', + of: [ + { + type: 'object', + name: 'item', + fields: [{name: 'label', type: 'string'}], + }, + ], + }, + ], + }, + ], +}) + +const childrenFieldNodes = [ + defineContainer({ + type: 'callout', + arrayField: 'children', + render: ({attributes, children}) =>
{children}
, + }), +] + describe('event.insert.block', () => { test('Scenario: Inserting block with custom _key', async () => { const {editor} = await createTestEditor({ @@ -3728,4 +3765,241 @@ describe('event.insert.block', () => { ]) }) }) + + test('Scenario: Inserting a gallery at root keeps its children', async () => { + const keyGenerator = createTestKeyGenerator() + const fooKey = keyGenerator() + const fooSpanKey = keyGenerator() + const galleryKey = keyGenerator() + const itemKey = keyGenerator() + const barKey = keyGenerator() + const barSpanKey = keyGenerator() + + const textPath = [{_key: barKey}, 'children', {_key: barSpanKey}] + + const initialValue = [ + textBlock(fooKey, fooSpanKey, 'foo'), + textBlock(barKey, barSpanKey, 'bar'), + ] + const patches: Array = [] + const {editor} = await createTestEditor({ + keyGenerator, + schemaDefinition: childrenFieldSchemaDefinition, + initialValue, + children: ( + { + if (event.type === 'patch') { + patches.push(event.patch) + } + }} + /> + ), + }) + + editor.send({ + type: 'select', + at: { + anchor: {path: textPath, offset: 3}, + focus: {path: textPath, offset: 3}, + }, + }) + editor.send({ + type: 'insert.block', + block: gallery(galleryKey, itemKey, 'a'), + placement: 'after', + }) + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value).toEqual([ + { + _type: 'block', + _key: fooKey, + children: [{_type: 'span', _key: fooSpanKey, text: 'foo', marks: []}], + markDefs: [], + style: 'normal', + }, + { + _type: 'block', + _key: barKey, + children: [{_type: 'span', _key: barSpanKey, text: 'bar', marks: []}], + markDefs: [], + style: 'normal', + }, + { + _type: 'gallery', + _key: galleryKey, + title: 'T', + children: [{_type: 'item', _key: itemKey, label: 'a'}], + }, + ]) + expect(applyAll(initialValue, patches)).toEqual([ + { + _type: 'block', + _key: fooKey, + children: [{_type: 'span', _key: fooSpanKey, text: 'foo', marks: []}], + markDefs: [], + style: 'normal', + }, + { + _type: 'block', + _key: barKey, + children: [{_type: 'span', _key: barSpanKey, text: 'bar', marks: []}], + markDefs: [], + style: 'normal', + }, + { + _type: 'gallery', + _key: galleryKey, + title: 'T', + children: [{_type: 'item', _key: itemKey, label: 'a'}], + }, + ]) + }) + }) + test('Scenario: Inserting a callout at root keeps its children', async () => { + const keyGenerator = createTestKeyGenerator() + const fooKey = keyGenerator() + const fooSpanKey = keyGenerator() + + const barKey = keyGenerator() + const barSpanKey = keyGenerator() + const calloutKey = keyGenerator() + const innerBlockKey = keyGenerator() + const innerSpanKey = keyGenerator() + + const textPath = [{_key: barKey}, 'children', {_key: barSpanKey}] + + const initialValue = [ + textBlock(fooKey, fooSpanKey, 'foo'), + textBlock(barKey, barSpanKey, 'bar'), + ] + const patches: Array = [] + const {editor, locator} = await createTestEditor({ + keyGenerator, + schemaDefinition: childrenFieldSchemaDefinition, + initialValue, + children: ( + <> + + { + if (event.type === 'patch') { + patches.push(event.patch) + } + }} + /> + + ), + }) + + editor.send({ + type: 'select', + at: { + anchor: {path: textPath, offset: 3}, + focus: {path: textPath, offset: 3}, + }, + }) + editor.send({ + type: 'insert.block', + block: callout(calloutKey, innerBlockKey, innerSpanKey, 'new'), + placement: 'after', + }) + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value).toEqual([ + { + _type: 'block', + _key: fooKey, + children: [{_type: 'span', _key: fooSpanKey, text: 'foo', marks: []}], + markDefs: [], + style: 'normal', + }, + { + _type: 'block', + _key: barKey, + children: [{_type: 'span', _key: barSpanKey, text: 'bar', marks: []}], + markDefs: [], + style: 'normal', + }, + { + _type: 'callout', + _key: calloutKey, + title: 'T', + children: [ + { + _type: 'block', + _key: innerBlockKey, + children: [ + {_type: 'span', _key: innerSpanKey, text: 'new', marks: []}, + ], + markDefs: [], + style: 'normal', + }, + ], + }, + ]) + expect(applyAll(initialValue, patches)).toEqual([ + { + _type: 'block', + _key: fooKey, + children: [{_type: 'span', _key: fooSpanKey, text: 'foo', marks: []}], + markDefs: [], + style: 'normal', + }, + { + _type: 'block', + _key: barKey, + children: [{_type: 'span', _key: barSpanKey, text: 'bar', marks: []}], + markDefs: [], + style: 'normal', + }, + { + _type: 'callout', + _key: calloutKey, + title: 'T', + children: [ + { + _type: 'block', + _key: innerBlockKey, + children: [ + {_type: 'span', _key: innerSpanKey, text: 'new', marks: []}, + ], + markDefs: [], + style: 'normal', + }, + ], + }, + ]) + }) + await vi.waitFor(() => { + expect(locator.element().textContent).toEqual('foobarnew') + }) + }) }) + +function textBlock(blockKey: string, spanKey: string, text: string) { + return { + _type: 'block', + _key: blockKey, + children: [{_type: 'span', _key: spanKey, text, marks: []}], + markDefs: [], + style: 'normal', + } +} + +function gallery(key: string, itemKey: string, label: string) { + return { + _type: 'gallery', + _key: key, + title: 'T', + children: [{_type: 'item', _key: itemKey, label}], + } +} + +function callout(key: string, blockKey: string, spanKey: string, text: string) { + return { + _type: 'callout', + _key: key, + title: 'T', + children: [textBlock(blockKey, spanKey, text)], + } +} diff --git a/packages/editor/tests/event.move.block.test.tsx b/packages/editor/tests/event.move.block.test.tsx index 4ff0e43143..61c7d11d41 100644 --- a/packages/editor/tests/event.move.block.test.tsx +++ b/packages/editor/tests/event.move.block.test.tsx @@ -1,6 +1,8 @@ +import {applyAll, type Patch} from '@portabletext/patches' import {createTestKeyGenerator} from '@portabletext/test' import {describe, expect, test, vi} from 'vitest' import {defineSchema} from '../src' +import {EventListenerPlugin} from '../src/plugins/plugin.event-listener' import {createTestEditor} from '../src/test/vitest' const keyGenerator = createTestKeyGenerator() @@ -23,6 +25,28 @@ const bar = { style: 'normal', } +const childrenFieldSchemaDefinition = defineSchema({ + blockObjects: [ + { + name: 'gallery', + fields: [ + {name: 'title', type: 'string'}, + { + name: 'children', + type: 'array', + of: [ + { + type: 'object', + name: 'item', + fields: [{name: 'label', type: 'string'}], + }, + ], + }, + ], + }, + ], +}) + describe('event.move.block down', () => { test('Scenario: Moving block object down', async () => { const {editor} = await createTestEditor({ @@ -79,6 +103,85 @@ describe('event.move.block down', () => { expect(editor.getSnapshot().context.value).toEqual([bar, image, foo]) }) }) + + test('Scenario: Moving a gallery down keeps its children', async () => { + const keyGenerator = createTestKeyGenerator() + const fooKey = keyGenerator() + const fooSpanKey = keyGenerator() + const galleryKey = keyGenerator() + const itemKey = keyGenerator() + const barKey = keyGenerator() + const barSpanKey = keyGenerator() + + const initialValue = [ + textBlock(fooKey, fooSpanKey, 'foo'), + gallery(galleryKey, itemKey, 'a'), + textBlock(barKey, barSpanKey, 'bar'), + ] + const patches: Array = [] + const {editor} = await createTestEditor({ + keyGenerator, + schemaDefinition: childrenFieldSchemaDefinition, + initialValue, + children: ( + { + if (event.type === 'patch') { + patches.push(event.patch) + } + }} + /> + ), + }) + + editor.send({type: 'move.block down', at: [{_key: galleryKey}]}) + await vi.waitFor(() => { + expect(applyAll(initialValue, patches)).toEqual([ + { + _type: 'block', + _key: fooKey, + children: [{_type: 'span', _key: fooSpanKey, text: 'foo', marks: []}], + markDefs: [], + style: 'normal', + }, + { + _type: 'block', + _key: barKey, + children: [{_type: 'span', _key: barSpanKey, text: 'bar', marks: []}], + markDefs: [], + style: 'normal', + }, + { + _type: 'gallery', + _key: galleryKey, + title: 'T', + children: [{_type: 'item', _key: itemKey, label: 'a'}], + }, + ]) + expect(editor.getSnapshot().context.value).toEqual([ + { + _type: 'block', + _key: fooKey, + children: [{_type: 'span', _key: fooSpanKey, text: 'foo', marks: []}], + markDefs: [], + style: 'normal', + }, + { + _type: 'block', + _key: barKey, + children: [{_type: 'span', _key: barSpanKey, text: 'bar', marks: []}], + markDefs: [], + style: 'normal', + }, + { + _type: 'gallery', + _key: galleryKey, + title: 'T', + children: [{_type: 'item', _key: itemKey, label: 'a'}], + }, + ]) + }) + }) }) describe('event.move.block up', () => { @@ -356,3 +459,12 @@ function textBlock(blockKey: string, spanKey: string, text: string) { style: 'normal', } } + +function gallery(key: string, itemKey: string, label: string) { + return { + _type: 'gallery', + _key: key, + title: 'T', + children: [{_type: 'item', _key: itemKey, label}], + } +} diff --git a/packages/editor/tests/event.paste.test.tsx b/packages/editor/tests/event.paste.test.tsx index cc35641503..7df8dd363e 100644 --- a/packages/editor/tests/event.paste.test.tsx +++ b/packages/editor/tests/event.paste.test.tsx @@ -1,16 +1,58 @@ import {htmlToPortableText, type ObjectMatcher} from '@portabletext/html' +import {applyAll, type Patch} from '@portabletext/patches' import {defineSchema} from '@portabletext/schema' import {createTestKeyGenerator, toTextspec} from '@portabletext/test' import {describe, expect, test, vi} from 'vitest' import {userEvent} from 'vitest/browser' import {effect, forward, raise} from '../src/behaviors/behavior.types.action' import {defineBehavior} from '../src/behaviors/behavior.types.behavior' -import {safeParse} from '../src/internal-utils/safe-json' +import {safeParse, safeStringify} from '../src/internal-utils/safe-json' import {BehaviorPlugin} from '../src/plugins/plugin.behavior' +import {EventListenerPlugin} from '../src/plugins/plugin.event-listener' import {NodePlugin} from '../src/plugins/plugin.node' import {defineContainer} from '../src/renderers/renderer.types' import {createTestEditor} from '../src/test/vitest' +const childrenFieldSchemaDefinition = defineSchema({ + blockObjects: [ + { + name: 'gallery', + fields: [ + {name: 'title', type: 'string'}, + { + name: 'children', + type: 'array', + of: [ + { + type: 'object', + name: 'item', + fields: [{name: 'label', type: 'string'}], + }, + ], + }, + ], + }, + { + name: 'section', + fields: [ + { + name: 'content', + type: 'array', + of: [{type: 'block'}, {type: 'gallery'}], + }, + ], + }, + ], +}) + +const childrenFieldNodes = [ + defineContainer({ + type: 'section', + arrayField: 'content', + render: ({attributes, children}) =>
{children}
, + }), +] + describe('event.clipboard.paste', () => { test('Scenario: Cut/paste block object', async () => { const keyGenerator = createTestKeyGenerator() @@ -873,4 +915,289 @@ describe('event.clipboard.paste', () => { ]) }) }) + + test('Scenario: Pasting a gallery copied at root keeps its children', async () => { + const keyGenerator = createTestKeyGenerator() + const fooKey = keyGenerator() + const fooSpanKey = keyGenerator() + const galleryKey = keyGenerator() + const itemKey = keyGenerator() + const barKey = keyGenerator() + const barSpanKey = keyGenerator() + + const textPath1 = [{_key: galleryKey}] + const textPath2 = [{_key: barKey}, 'children', {_key: barSpanKey}] + + const initialValue = [ + textBlock(fooKey, fooSpanKey, 'foo'), + gallery(galleryKey, itemKey, 'a'), + textBlock(barKey, barSpanKey, 'bar'), + ] + const patches: Array = [] + const {editor} = await createTestEditor({ + keyGenerator, + schemaDefinition: childrenFieldSchemaDefinition, + initialValue, + children: ( + { + if (event.type === 'patch') { + patches.push(event.patch) + } + }} + /> + ), + }) + + editor.send({ + type: 'select', + at: { + anchor: {path: textPath1, offset: 0}, + focus: {path: textPath1, offset: 0}, + }, + }) + await vi.waitFor(() => { + expect(editor.getSnapshot().context.selection).toEqual({ + anchor: {path: textPath1, offset: 0}, + focus: {path: textPath1, offset: 0}, + backward: false, + }) + }) + const dataTransfer = new DataTransfer() + editor.send({ + type: 'clipboard.copy', + originEvent: {dataTransfer}, + position: { + selection: { + anchor: {path: textPath1, offset: 0}, + focus: {path: textPath1, offset: 0}, + }, + }, + }) + editor.send({ + type: 'select', + at: { + anchor: {path: textPath2, offset: 3}, + focus: {path: textPath2, offset: 3}, + }, + }) + editor.send({ + type: 'clipboard.paste', + originEvent: {dataTransfer}, + position: {selection: editor.getSnapshot().context.selection!}, + }) + await vi.waitFor(() => { + const pastedBlockKey = editor.getSnapshot().context.value[3]!._key + expect(editor.getSnapshot().context.value).toEqual([ + { + _type: 'block', + _key: fooKey, + children: [{_type: 'span', _key: fooSpanKey, text: 'foo', marks: []}], + markDefs: [], + style: 'normal', + }, + { + _type: 'gallery', + _key: galleryKey, + title: 'T', + children: [{_type: 'item', _key: itemKey, label: 'a'}], + }, + { + _type: 'block', + _key: barKey, + children: [{_type: 'span', _key: barSpanKey, text: 'bar', marks: []}], + markDefs: [], + style: 'normal', + }, + { + _type: 'gallery', + _key: pastedBlockKey, + title: 'T', + children: [{_type: 'item', _key: itemKey, label: 'a'}], + }, + ]) + expect(applyAll(initialValue, patches)).toEqual([ + { + _type: 'block', + _key: fooKey, + children: [{_type: 'span', _key: fooSpanKey, text: 'foo', marks: []}], + markDefs: [], + style: 'normal', + }, + { + _type: 'gallery', + _key: galleryKey, + title: 'T', + children: [{_type: 'item', _key: itemKey, label: 'a'}], + }, + { + _type: 'block', + _key: barKey, + children: [{_type: 'span', _key: barSpanKey, text: 'bar', marks: []}], + markDefs: [], + style: 'normal', + }, + { + _type: 'gallery', + _key: pastedBlockKey, + title: 'T', + children: [{_type: 'item', _key: itemKey, label: 'a'}], + }, + ]) + }) + }) + + test('Scenario: Pasting a gallery inside a section keeps its children', async () => { + const keyGenerator = createTestKeyGenerator() + const fooKey = keyGenerator() + const fooSpanKey = keyGenerator() + const galleryKey = keyGenerator() + const itemKey = keyGenerator() + const barKey = keyGenerator() + const barSpanKey = keyGenerator() + + const sectionKey = keyGenerator() + const nestedPath = [ + {_key: sectionKey}, + 'content', + {_key: barKey}, + 'children', + {_key: barSpanKey}, + ] + + const initialValue = [ + section(sectionKey, [ + textBlock(fooKey, fooSpanKey, 'foo'), + textBlock(barKey, barSpanKey, 'bar'), + ]), + ] + const patches: Array = [] + const {editor} = await createTestEditor({ + keyGenerator, + schemaDefinition: childrenFieldSchemaDefinition, + initialValue, + children: ( + <> + + { + if (event.type === 'patch') { + patches.push(event.patch) + } + }} + /> + + ), + }) + + const dataTransfer = new DataTransfer() + dataTransfer.setData( + 'application/x-portable-text', + safeStringify([gallery(galleryKey, itemKey, 'a')]), + ) + editor.send({ + type: 'select', + at: { + anchor: {path: nestedPath, offset: 3}, + focus: {path: nestedPath, offset: 3}, + }, + }) + editor.send({ + type: 'clipboard.paste', + originEvent: {dataTransfer}, + position: {selection: editor.getSnapshot().context.selection!}, + }) + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value).toEqual([ + { + _type: 'section', + _key: sectionKey, + content: [ + { + _type: 'block', + _key: fooKey, + children: [ + {_type: 'span', _key: fooSpanKey, text: 'foo', marks: []}, + ], + markDefs: [], + style: 'normal', + }, + { + _type: 'block', + _key: barKey, + children: [ + {_type: 'span', _key: barSpanKey, text: 'bar', marks: []}, + ], + markDefs: [], + style: 'normal', + }, + { + _type: 'gallery', + _key: galleryKey, + title: 'T', + children: [{_type: 'item', _key: itemKey, label: 'a'}], + }, + ], + }, + ]) + expect(applyAll(initialValue, patches)).toEqual([ + { + _type: 'section', + _key: sectionKey, + content: [ + { + _type: 'block', + _key: fooKey, + children: [ + {_type: 'span', _key: fooSpanKey, text: 'foo', marks: []}, + ], + markDefs: [], + style: 'normal', + }, + { + _type: 'block', + _key: barKey, + children: [ + {_type: 'span', _key: barSpanKey, text: 'bar', marks: []}, + ], + markDefs: [], + style: 'normal', + }, + { + _type: 'gallery', + _key: galleryKey, + title: 'T', + children: [{_type: 'item', _key: itemKey, label: 'a'}], + }, + ], + }, + ]) + }) + }) }) + +function textBlock(blockKey: string, spanKey: string, text: string) { + return { + _type: 'block', + _key: blockKey, + children: [{_type: 'span', _key: spanKey, text, marks: []}], + markDefs: [], + style: 'normal', + } +} + +function gallery(key: string, itemKey: string, label: string) { + return { + _type: 'gallery', + _key: key, + title: 'T', + children: [{_type: 'item', _key: itemKey, label}], + } +} + +function section( + key: string, + content: Array | ReturnType>, +) { + return {_type: 'section', _key: key, content} +} diff --git a/packages/editor/tests/event.update-value.container.test.tsx b/packages/editor/tests/event.update-value.container.test.tsx index 591fd1b587..ec25601fe9 100644 --- a/packages/editor/tests/event.update-value.container.test.tsx +++ b/packages/editor/tests/event.update-value.container.test.tsx @@ -56,6 +56,26 @@ const codeBlockContainer = [ }), ] +const childrenFieldSchemaDefinition = defineSchema({ + blockObjects: [ + { + name: 'callout', + fields: [ + {name: 'title', type: 'string'}, + {name: 'children', type: 'array', of: [{type: 'block'}]}, + ], + }, + ], +}) + +const childrenFieldNodes = [ + defineContainer({ + type: 'callout', + arrayField: 'children', + render: ({attributes, children}) =>
{children}
, + }), +] + describe('event.update value with containers', () => { test('Scenario: Update text inside a container line', async () => { const keyGenerator = createTestKeyGenerator() @@ -1114,4 +1134,269 @@ describe('event.update value with containers', () => { ) }) }) + + test('Scenario: Updating a children container renders its new content', async () => { + const keyGenerator = createTestKeyGenerator() + const fooKey = keyGenerator() + const fooSpanKey = keyGenerator() + + const barKey = keyGenerator() + const barSpanKey = keyGenerator() + const calloutKey = keyGenerator() + const innerBlockKey = keyGenerator() + const innerSpanKey = keyGenerator() + + const initialValue = [ + textBlock(fooKey, fooSpanKey, 'foo'), + callout(calloutKey, innerBlockKey, innerSpanKey, 'inside'), + textBlock(barKey, barSpanKey, 'bar'), + ] + const {editor, locator} = await createTestEditor({ + keyGenerator, + schemaDefinition: childrenFieldSchemaDefinition, + initialValue, + children: , + }) + + await vi.waitFor(() => { + expect(locator.element().textContent).toEqual('fooinsidebar') + }) + editor.send({ + type: 'update value', + value: [ + textBlock(fooKey, fooSpanKey, 'foo'), + callout(calloutKey, innerBlockKey, innerSpanKey, 'inside!'), + textBlock(barKey, barSpanKey, 'bar'), + ], + }) + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value).toEqual([ + { + _type: 'block', + _key: fooKey, + children: [{_type: 'span', _key: fooSpanKey, text: 'foo', marks: []}], + markDefs: [], + style: 'normal', + }, + { + _type: 'callout', + _key: calloutKey, + title: 'T', + children: [ + { + _type: 'block', + _key: innerBlockKey, + children: [ + {_type: 'span', _key: innerSpanKey, text: 'inside!', marks: []}, + ], + markDefs: [], + style: 'normal', + }, + ], + }, + { + _type: 'block', + _key: barKey, + children: [{_type: 'span', _key: barSpanKey, text: 'bar', marks: []}], + markDefs: [], + style: 'normal', + }, + ]) + }) + await vi.waitFor(() => { + expect(locator.element().textContent).toEqual('fooinside!bar') + }) + }) + + test('Scenario: Updating a children container preserves its caret', async () => { + const keyGenerator = createTestKeyGenerator() + const fooKey = keyGenerator() + const fooSpanKey = keyGenerator() + + const barKey = keyGenerator() + const barSpanKey = keyGenerator() + const calloutKey = keyGenerator() + const innerBlockKey = keyGenerator() + const innerSpanKey = keyGenerator() + + const nestedPath = [ + {_key: calloutKey}, + 'children', + {_key: innerBlockKey}, + 'children', + {_key: innerSpanKey}, + ] + + const initialValue = [ + textBlock(fooKey, fooSpanKey, 'foo'), + callout(calloutKey, innerBlockKey, innerSpanKey, 'inside'), + textBlock(barKey, barSpanKey, 'bar'), + ] + const {editor, locator} = await createTestEditor({ + keyGenerator, + schemaDefinition: childrenFieldSchemaDefinition, + initialValue, + children: , + }) + + await vi.waitFor(() => { + expect(locator.element().textContent).toEqual('fooinsidebar') + }) + editor.send({ + type: 'select', + at: { + anchor: {path: nestedPath, offset: 3}, + focus: {path: nestedPath, offset: 3}, + }, + }) + await vi.waitFor(() => { + expect(editor.getSnapshot().context.selection).toEqual({ + anchor: {path: nestedPath, offset: 3}, + focus: {path: nestedPath, offset: 3}, + backward: false, + }) + }) + editor.send({ + type: 'update value', + value: [ + textBlock(fooKey, fooSpanKey, 'foo'), + callout(calloutKey, innerBlockKey, innerSpanKey, 'inside!'), + textBlock(barKey, barSpanKey, 'bar'), + ], + }) + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value).toEqual([ + { + _type: 'block', + _key: fooKey, + children: [{_type: 'span', _key: fooSpanKey, text: 'foo', marks: []}], + markDefs: [], + style: 'normal', + }, + { + _type: 'callout', + _key: calloutKey, + title: 'T', + children: [ + { + _type: 'block', + _key: innerBlockKey, + children: [ + {_type: 'span', _key: innerSpanKey, text: 'inside!', marks: []}, + ], + markDefs: [], + style: 'normal', + }, + ], + }, + { + _type: 'block', + _key: barKey, + children: [{_type: 'span', _key: barSpanKey, text: 'bar', marks: []}], + markDefs: [], + style: 'normal', + }, + ]) + }) + await vi.waitFor(() => { + expect(locator.element().textContent).toEqual('fooinside!bar') + }) + await vi.waitFor(() => { + expect(editor.getSnapshot().context.selection).toEqual({ + anchor: {path: nestedPath, offset: 3}, + focus: {path: nestedPath, offset: 3}, + backward: false, + }) + }) + }) + test('Scenario: Replacing a callout at root keeps and renders its incoming blocks', async () => { + const keyGenerator = createTestKeyGenerator() + const fooKey = keyGenerator() + const fooSpanKey = keyGenerator() + const calloutKey = keyGenerator() + const innerBlockKey = keyGenerator() + const innerSpanKey = keyGenerator() + const barKey = keyGenerator() + const barSpanKey = keyGenerator() + const replacementKey = keyGenerator() + const replacementBlockKey = keyGenerator() + const replacementSpanKey = keyGenerator() + const {editor, locator} = await createTestEditor({ + keyGenerator, + schemaDefinition: childrenFieldSchemaDefinition, + initialValue: [ + textBlock(fooKey, fooSpanKey, 'foo'), + callout(calloutKey, innerBlockKey, innerSpanKey, 'baz'), + textBlock(barKey, barSpanKey, 'bar'), + ], + children: , + }) + editor.send({ + type: 'update value', + value: [ + textBlock(fooKey, fooSpanKey, 'foo'), + callout(replacementKey, replacementBlockKey, replacementSpanKey, 'baz'), + textBlock(barKey, barSpanKey, 'bar'), + ], + }) + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value).toEqual([ + { + _type: 'block', + _key: fooKey, + children: [{_type: 'span', _key: fooSpanKey, text: 'foo', marks: []}], + markDefs: [], + style: 'normal', + }, + { + _type: 'callout', + _key: replacementKey, + title: 'T', + children: [ + { + _type: 'block', + _key: replacementBlockKey, + children: [ + { + _type: 'span', + _key: replacementSpanKey, + text: 'baz', + marks: [], + }, + ], + markDefs: [], + style: 'normal', + }, + ], + }, + { + _type: 'block', + _key: barKey, + children: [{_type: 'span', _key: barSpanKey, text: 'bar', marks: []}], + markDefs: [], + style: 'normal', + }, + ]) + expect(locator.element().textContent).toEqual('foobazbar') + }) + }) }) + +function textBlock(blockKey: string, spanKey: string, text: string) { + return { + _type: 'block', + _key: blockKey, + children: [{_type: 'span', _key: spanKey, text, marks: []}], + markDefs: [], + style: 'normal', + } +} + +function callout(key: string, blockKey: string, spanKey: string, text: string) { + return { + _type: 'callout', + _key: key, + title: 'T', + children: [textBlock(blockKey, spanKey, text)], + } +} diff --git a/packages/editor/tests/event.update-value.test.tsx b/packages/editor/tests/event.update-value.test.tsx index f12a332abb..97839080bd 100644 --- a/packages/editor/tests/event.update-value.test.tsx +++ b/packages/editor/tests/event.update-value.test.tsx @@ -13,6 +13,28 @@ import { import {EventListenerPlugin} from '../src/plugins/plugin.event-listener' import {createTestEditor} from '../src/test/vitest' +const childrenFieldSchemaDefinition = defineSchema({ + blockObjects: [ + { + name: 'gallery', + fields: [ + {name: 'title', type: 'string'}, + { + name: 'children', + type: 'array', + of: [ + { + type: 'object', + name: 'item', + fields: [{name: 'label', type: 'string'}], + }, + ], + }, + ], + }, + ], +}) + describe('event.update value', () => { test('Scenario: Clearing placeholder value', async () => { const {editor} = await createTestEditor({ @@ -2903,6 +2925,248 @@ describe('event.update value', () => { ]) }) }) + + test('Scenario: Loading a gallery keeps its children without patches', async () => { + const keyGenerator = createTestKeyGenerator() + const fooKey = keyGenerator() + const fooSpanKey = keyGenerator() + const galleryKey = keyGenerator() + const itemKey = keyGenerator() + const barKey = keyGenerator() + const barSpanKey = keyGenerator() + + const textPath1 = [{_key: fooKey}, 'children', {_key: fooSpanKey}] + + const initialValue = [ + textBlock(fooKey, fooSpanKey, 'foo'), + gallery(galleryKey, itemKey, 'a'), + textBlock(barKey, barSpanKey, 'bar'), + ] + const patches: Array = [] + const {editor} = await createTestEditor({ + keyGenerator, + schemaDefinition: childrenFieldSchemaDefinition, + initialValue, + children: ( + { + if (event.type === 'patch') { + patches.push(event.patch) + } + }} + /> + ), + }) + + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value).toEqual([ + { + _type: 'block', + _key: fooKey, + children: [{_type: 'span', _key: fooSpanKey, text: 'foo', marks: []}], + markDefs: [], + style: 'normal', + }, + { + _type: 'gallery', + _key: galleryKey, + title: 'T', + children: [{_type: 'item', _key: itemKey, label: 'a'}], + }, + { + _type: 'block', + _key: barKey, + children: [{_type: 'span', _key: barSpanKey, text: 'bar', marks: []}], + markDefs: [], + style: 'normal', + }, + ]) + expect(patches).toEqual([]) + }) + editor.send({ + type: 'select', + at: { + anchor: {path: textPath1, offset: 3}, + focus: {path: textPath1, offset: 3}, + }, + }) + editor.send({type: 'insert.text', text: 'x'}) + await vi.waitFor(() => { + expect(patches).toEqual([ + { + type: 'diffMatchPatch', + origin: 'local', + path: [{_key: fooKey}, 'children', {_key: fooSpanKey}, 'text'], + value: '@@ -1,3 +1,4 @@\n foo\n+x\n', + }, + ]) + }) + }) + + test('Scenario: Updating a gallery replaces its children', async () => { + const keyGenerator = createTestKeyGenerator() + const fooKey = keyGenerator() + const fooSpanKey = keyGenerator() + const galleryKey = keyGenerator() + const itemKey = keyGenerator() + const barKey = keyGenerator() + const barSpanKey = keyGenerator() + + const initialValue = [ + textBlock(fooKey, fooSpanKey, 'foo'), + gallery(galleryKey, itemKey, 'a'), + textBlock(barKey, barSpanKey, 'bar'), + ] + const patches: Array = [] + const {editor} = await createTestEditor({ + keyGenerator, + schemaDefinition: childrenFieldSchemaDefinition, + initialValue, + children: ( + { + if (event.type === 'patch') { + patches.push(event.patch) + } + }} + /> + ), + }) + + editor.send({ + type: 'update value', + value: [ + textBlock(fooKey, fooSpanKey, 'foo'), + gallery(galleryKey, itemKey, 'b'), + textBlock(barKey, barSpanKey, 'bar'), + ], + }) + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value).toEqual([ + { + _type: 'block', + _key: fooKey, + children: [{_type: 'span', _key: fooSpanKey, text: 'foo', marks: []}], + markDefs: [], + style: 'normal', + }, + { + _type: 'gallery', + _key: galleryKey, + title: 'T', + children: [{_type: 'item', _key: itemKey, label: 'b'}], + }, + { + _type: 'block', + _key: barKey, + children: [{_type: 'span', _key: barSpanKey, text: 'bar', marks: []}], + markDefs: [], + style: 'normal', + }, + ]) + }) + }) + + test('Scenario: Replacing a gallery at root keeps the incoming children', async () => { + const keyGenerator = createTestKeyGenerator() + const fooKey = keyGenerator() + const fooSpanKey = keyGenerator() + const galleryKey = keyGenerator() + const itemKey = keyGenerator() + const barKey = keyGenerator() + const barSpanKey = keyGenerator() + const replacementKey = keyGenerator() + const replacementItemKey = keyGenerator() + + const {editor} = await createTestEditor({ + keyGenerator, + schemaDefinition: childrenFieldSchemaDefinition, + initialValue: [ + textBlock(fooKey, fooSpanKey, 'foo'), + gallery(galleryKey, itemKey, 'a'), + textBlock(barKey, barSpanKey, 'bar'), + ], + }) + editor.send({ + type: 'update value', + value: [ + textBlock(fooKey, fooSpanKey, 'foo'), + gallery(replacementKey, replacementItemKey, 'b'), + textBlock(barKey, barSpanKey, 'bar'), + ], + }) + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value).toEqual([ + { + _type: 'block', + _key: fooKey, + children: [{_type: 'span', _key: fooSpanKey, text: 'foo', marks: []}], + markDefs: [], + style: 'normal', + }, + { + _type: 'gallery', + _key: replacementKey, + title: 'T', + children: [{_type: 'item', _key: replacementItemKey, label: 'b'}], + }, + { + _type: 'block', + _key: barKey, + children: [{_type: 'span', _key: barSpanKey, text: 'bar', marks: []}], + markDefs: [], + style: 'normal', + }, + ]) + }) + }) + + test('Scenario: Removing a gallery children field removes it from the held value', async () => { + const keyGenerator = createTestKeyGenerator() + const fooKey = keyGenerator() + const fooSpanKey = keyGenerator() + const galleryKey = keyGenerator() + const itemKey = keyGenerator() + const barKey = keyGenerator() + const barSpanKey = keyGenerator() + + const {editor} = await createTestEditor({ + keyGenerator, + schemaDefinition: childrenFieldSchemaDefinition, + initialValue: [ + textBlock(fooKey, fooSpanKey, 'foo'), + gallery(galleryKey, itemKey, 'a'), + textBlock(barKey, barSpanKey, 'bar'), + ], + }) + editor.send({ + type: 'update value', + value: [ + textBlock(fooKey, fooSpanKey, 'foo'), + {_type: 'gallery', _key: galleryKey, title: 'T'}, + textBlock(barKey, barSpanKey, 'bar'), + ], + }) + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value).toEqual([ + { + _type: 'block', + _key: fooKey, + children: [{_type: 'span', _key: fooSpanKey, text: 'foo', marks: []}], + markDefs: [], + style: 'normal', + }, + {_type: 'gallery', _key: galleryKey, title: 'T'}, + { + _type: 'block', + _key: barKey, + children: [{_type: 'span', _key: barSpanKey, text: 'bar', marks: []}], + markDefs: [], + style: 'normal', + }, + ]) + }) + }) }) describe('event.update value: adjacent same-mark spans', () => { @@ -4718,3 +4982,12 @@ function textBlock( style: 'normal', } } + +function gallery(key: string, itemKey: string, label: string) { + return { + _type: 'gallery', + _key: key, + title: 'T', + children: [{_type: 'item', _key: itemKey, label}], + } +} diff --git a/packages/editor/tests/remote-patches.non-object.test.tsx b/packages/editor/tests/remote-patches.non-object.test.tsx new file mode 100644 index 0000000000..3288a9f2dc --- /dev/null +++ b/packages/editor/tests/remote-patches.non-object.test.tsx @@ -0,0 +1,387 @@ +import type {Patch} from '@portabletext/patches' +import {defineSchema} from '@portabletext/schema' +import {createTestKeyGenerator} from '@portabletext/test' +import {expect, test, vi} from 'vitest' +import {EventListenerPlugin} from '../src/plugins/plugin.event-listener' +import {NodePlugin} from '../src/plugins/plugin.node' +import {defineContainer} from '../src/renderers/renderer.types' +import {createTestEditor} from '../src/test/vitest' + +const container = defineContainer({ + type: 'container', + arrayField: 'content', + render: ({children}) => <>{children}, +}) +const schemaDefinition = defineSchema({ + blockObjects: [ + { + name: 'container', + fields: [{name: 'content', type: 'array', of: [{type: 'block'}]}], + }, + ], +}) +const keys = createTestKeyGenerator() +const blockKey = keys() +const spanKey = keys() +const containerKey = keys() +const nestedBlockKey = keys() +const nestedSpanKey = keys() +const secondSpanKey = keys() +const block = { + _type: 'block', + _key: blockKey, + children: [{_type: 'span', _key: spanKey, text: 'foo', marks: []}], + markDefs: [], + style: 'normal', +} +const nestedBlock = { + _type: 'block', + _key: nestedBlockKey, + children: [{_type: 'span', _key: nestedSpanKey, text: 'foo', marks: []}], + markDefs: [], + style: 'normal', +} + +test.each([ + { + name: 'a remote insert at the root', + initialValue: [block], + event: { + type: 'apply', + patches: [ + { + type: 'insert', + path: [{_key: blockKey}], + position: 'after', + items: ['oops'], + origin: 'remote', + }, + ], + snapshot: undefined, + }, + expectedValue: [block], + expectedPatches: [{type: 'unset', path: [1], origin: 'local'}], + }, + { + name: "a remote insert in a root text block's children", + initialValue: [block], + event: { + type: 'apply', + patches: [ + { + type: 'insert', + path: [{_key: blockKey}, 'children', {_key: spanKey}], + position: 'after', + items: [42], + origin: 'remote', + }, + ], + snapshot: undefined, + }, + expectedValue: [block], + expectedPatches: [ + {type: 'unset', path: [{_key: blockKey}, 'children', 1], origin: 'local'}, + ], + }, + { + name: "a remote insert in a container's array field", + initialValue: [ + {_type: 'container', _key: containerKey, content: [nestedBlock]}, + ], + event: { + type: 'apply', + patches: [ + { + type: 'insert', + path: [{_key: containerKey}, 'content', {_key: nestedBlockKey}], + position: 'after', + items: ['oops'], + origin: 'remote', + }, + ], + snapshot: undefined, + }, + expectedValue: [ + {_type: 'container', _key: containerKey, content: [nestedBlock]}, + ], + expectedPatches: [ + { + type: 'unset', + path: [{_key: containerKey}, 'content', 1], + origin: 'local', + }, + ], + }, + { + name: 'a remote insert in a text block inside a container', + initialValue: [ + {_type: 'container', _key: containerKey, content: [nestedBlock]}, + ], + event: { + type: 'apply', + patches: [ + { + type: 'insert', + path: [ + {_key: containerKey}, + 'content', + {_key: nestedBlockKey}, + 'children', + {_key: nestedSpanKey}, + ], + position: 'after', + items: [42], + origin: 'remote', + }, + ], + snapshot: undefined, + }, + expectedValue: [ + {_type: 'container', _key: containerKey, content: [nestedBlock]}, + ], + expectedPatches: [ + { + type: 'unset', + path: [ + {_key: containerKey}, + 'content', + {_key: nestedBlockKey}, + 'children', + 1, + ], + origin: 'local', + }, + ], + }, + { + name: '`initialValue` in a container', + initialValue: [ + {_type: 'container', _key: containerKey, content: [nestedBlock, 'oops']}, + ], + event: undefined, + expectedValue: [ + {_type: 'container', _key: containerKey, content: [nestedBlock]}, + ], + expectedPatches: [], + }, + { + name: '`update value` into a container text block', + initialValue: [ + {_type: 'container', _key: containerKey, content: [nestedBlock]}, + ], + event: { + type: 'update value', + value: [ + { + _type: 'container', + _key: containerKey, + content: [{...nestedBlock, children: [...nestedBlock.children, 42]}], + }, + ], + }, + expectedValue: [ + {_type: 'container', _key: containerKey, content: [nestedBlock]}, + ], + expectedPatches: [], + }, + { + name: 'two primitives in one remote insert', + initialValue: [block], + event: { + type: 'apply', + patches: [ + { + type: 'insert', + path: [{_key: blockKey}], + position: 'after', + items: ['oops', 42], + origin: 'remote', + }, + ], + snapshot: undefined, + }, + expectedValue: [block], + expectedPatches: [ + {type: 'unset', path: [2], origin: 'local'}, + {type: 'unset', path: [1], origin: 'local'}, + ], + }, + { + name: 'a primitive between two spans', + initialValue: [ + { + ...block, + children: [ + {_type: 'span', _key: spanKey, text: 'foo', marks: []}, + {_type: 'span', _key: secondSpanKey, text: 'bar', marks: []}, + ], + }, + ], + event: { + type: 'apply', + patches: [ + { + type: 'insert', + path: [{_key: blockKey}, 'children', {_key: spanKey}], + position: 'after', + items: ['oops'], + origin: 'remote', + }, + ], + snapshot: undefined, + }, + expectedValue: [ + { + ...block, + children: [ + {_type: 'span', _key: spanKey, text: 'foo', marks: []}, + {_type: 'span', _key: secondSpanKey, text: 'bar', marks: []}, + ], + }, + ], + expectedPatches: [ + {type: 'unset', path: [{_key: blockKey}, 'children', 1], origin: 'local'}, + ], + }, + { + name: '`true` in a remote insert', + initialValue: [block], + event: { + type: 'apply', + patches: [ + { + type: 'insert', + path: [{_key: blockKey}], + position: 'after', + items: [true], + origin: 'remote', + }, + ], + snapshot: undefined, + }, + expectedValue: [block], + expectedPatches: [{type: 'unset', path: [1], origin: 'local'}], + }, +])( + 'Scenario: $name is removed', + async ({initialValue, event, expectedValue, expectedPatches}) => { + const patches: Array = [] + const {editor, locator} = await createTestEditor({ + children: ( + <> + { + if (editorEvent.type === 'patch') { + patches.push(editorEvent.patch) + } + }} + /> + + + ), + initialValue: initialValue as never, + keyGenerator: createTestKeyGenerator('generated-'), + schemaDefinition, + }) + if (event) { + editor.send(event as never) + } + await vi.waitFor(() => + expect(editor.getSnapshot().context.value).toEqual(expectedValue), + ) + await expect.element(locator).toBeVisible() + await vi.waitFor(() => expect(patches).toEqual(expectedPatches)) + }, +) + +test('Scenario: undo after a remote removal reverts only the local edit', async () => { + const keyGenerator = createTestKeyGenerator() + const undoBlockKey = keyGenerator() + const undoSpanKey = keyGenerator() + const initialValue = [ + { + _type: 'block', + _key: undoBlockKey, + children: [{_type: 'span', _key: undoSpanKey, text: 'foo', marks: []}], + markDefs: [], + style: 'normal', + }, + ] + const patches: Array = [] + const {editor} = await createTestEditor({ + children: ( + { + if (event.type === 'patch') { + patches.push(event.patch) + } + }} + /> + ), + initialValue, + keyGenerator, + }) + editor.send({ + type: 'select', + at: { + anchor: { + path: [{_key: undoBlockKey}, 'children', {_key: undoSpanKey}], + offset: 3, + }, + focus: { + path: [{_key: undoBlockKey}, 'children', {_key: undoSpanKey}], + offset: 3, + }, + }, + }) + editor.send({type: 'insert.text', text: '!'}) + await vi.waitFor(() => + expect(editor.getSnapshot().context.value).toEqual([ + { + ...initialValue[0], + children: [{_type: 'span', _key: undoSpanKey, text: 'foo!', marks: []}], + }, + ]), + ) + editor.send({ + type: 'apply', + patches: [ + { + type: 'insert', + path: [{_key: undoBlockKey}], + position: 'after', + items: ['oops'], + origin: 'remote', + }, + ], + }) + await vi.waitFor(() => + expect(editor.getSnapshot().context.value).toEqual([ + { + ...initialValue[0], + children: [{_type: 'span', _key: undoSpanKey, text: 'foo!', marks: []}], + }, + ]), + ) + editor.send({type: 'history.undo'}) + await vi.waitFor(() => + expect(editor.getSnapshot().context.value).toEqual(initialValue), + ) + await vi.waitFor(() => + expect(patches).toEqual([ + { + type: 'diffMatchPatch', + path: [{_key: undoBlockKey}, 'children', {_key: undoSpanKey}, 'text'], + value: '@@ -1,3 +1,4 @@\n foo\n+!\n', + origin: 'local', + }, + {type: 'unset', path: [1], origin: 'local'}, + { + type: 'diffMatchPatch', + path: [{_key: undoBlockKey}, 'children', {_key: undoSpanKey}, 'text'], + value: '@@ -1,4 +1,3 @@\n foo\n-!\n', + origin: 'local', + }, + ]), + ) +}) diff --git a/packages/editor/tests/selection-degenerate-caret.test.tsx b/packages/editor/tests/selection-degenerate-caret.test.tsx index 3b6ced4904..b8d02a4884 100644 --- a/packages/editor/tests/selection-degenerate-caret.test.tsx +++ b/packages/editor/tests/selection-degenerate-caret.test.tsx @@ -43,13 +43,12 @@ const initialValue = [ describe('degenerate collapsed DOM carets', () => { test('Scenario: The caret is canonicalized even when the model selection does not change', async () => { - const {editor, locator} = await createTestEditor({ + const {editor} = await createTestEditor({ keyGenerator: createTestKeyGenerator(), schemaDefinition, initialValue, children: , }) - await expect.element(locator).toBeInTheDocument() const editable = document.querySelector('[contenteditable="true"]') const container = editable?.querySelector('[data-pt-block="container"]') @@ -85,13 +84,12 @@ describe('degenerate collapsed DOM carets', () => { }) test('Scenario: A caret parked on a container element is pushed into its text', async () => { - const {editor, locator} = await createTestEditor({ + const {editor} = await createTestEditor({ keyGenerator: createTestKeyGenerator(), schemaDefinition, initialValue, children: , }) - await expect.element(locator).toBeInTheDocument() const editable = document.querySelector('[contenteditable="true"]') const container = editable?.querySelector('[data-pt-block="container"]') diff --git a/packages/editor/tests/selection-emptied-container-child.test.tsx b/packages/editor/tests/selection-emptied-container-child.test.tsx index b1fd7dd4ad..13cfdefce1 100644 --- a/packages/editor/tests/selection-emptied-container-child.test.tsx +++ b/packages/editor/tests/selection-emptied-container-child.test.tsx @@ -215,7 +215,7 @@ describe('selection on an emptied container child', () => { }) async function selectAcrossCellText() { - const {editor, locator} = await createTestEditor({ + const {editor} = await createTestEditor({ keyGenerator: createTestKeyGenerator(), schemaDefinition, initialValue, @@ -228,7 +228,6 @@ async function selectAcrossCellText() { ), }) - await expect.element(locator).toBeInTheDocument() editor.send({ type: 'select', at: { diff --git a/packages/editor/tests/validation.test.tsx b/packages/editor/tests/validation.test.tsx index 1cd3fc070c..c8d9a7a7c6 100644 --- a/packages/editor/tests/validation.test.tsx +++ b/packages/editor/tests/validation.test.tsx @@ -1,3 +1,5 @@ +import {applyAll} from '@portabletext/patches' +import type {PortableTextBlock} from '@portabletext/schema' import {defineSchema} from '@portabletext/schema' import {createTestKeyGenerator, toTextspec} from '@portabletext/test' import {makeDiff, makePatches, stringifyPatches} from '@sanity/diff-match-patch' @@ -5,6 +7,7 @@ import {describe, expect, test, vi} from 'vitest' import type {EditorEmittedEvent} from '../src/editor/relay' import {EventListenerPlugin} from '../src/plugins/plugin.event-listener' import {createTestEditor} from '../src/test/vitest' +import type {InvalidValueResolution} from '../src/types/editor' describe('Value validation', () => { test('Initial value with `null` child results in a validation error', async () => { @@ -961,3 +964,579 @@ describe('Value validation', () => { ]) }) }) + +test('Scenario: initialValue resolves string in the middle at its value index', async () => { + const keyGenerator = createTestKeyGenerator() + const resolutions: Array = [] + + const foo = { + _type: 'block', + _key: keyGenerator(), + children: [{_type: 'span', _key: keyGenerator(), text: 'foo', marks: []}], + } + const bar = { + _type: 'block', + _key: keyGenerator(), + children: [{_type: 'span', _key: keyGenerator(), text: 'bar', marks: []}], + } + + const value = [foo, 'oops', bar] as unknown as Array + await createTestEditor({ + keyGenerator, + initialValue: value, + children: ( + { + if (event.type === 'invalid value') { + resolutions.push(event.resolution) + } + }} + /> + ), + }) + await vi.waitFor(() => { + expect(resolutions).toEqual([ + { + patches: [{type: 'unset', path: [1]}], + description: 'Block must be an object, got oops', + action: 'Unset invalid item', + item: 'oops', + i18n: { + description: + 'inputs.portable-text.invalid-value.not-an-object.description', + action: 'inputs.portable-text.invalid-value.not-an-object.action', + values: {index: 1}, + }, + }, + ]) + }) + expect(applyAll(value, resolutions[0]!.patches)).toEqual([ + { + _type: 'block', + _key: 'k0', + children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], + }, + { + _type: 'block', + _key: 'k2', + children: [{_type: 'span', _key: 'k3', text: 'bar', marks: []}], + }, + ]) +}) + +test('Scenario: initialValue resolves keyless block in the middle at its value index', async () => { + const keyGenerator = createTestKeyGenerator() + const resolutions: Array = [] + + const foo = { + _type: 'block', + _key: keyGenerator(), + children: [{_type: 'span', _key: keyGenerator(), text: 'foo', marks: []}], + } + const bar = { + _type: 'block', + _key: keyGenerator(), + children: [{_type: 'span', _key: keyGenerator(), text: 'bar', marks: []}], + } + const keyless = { + _type: 'block', + children: [{_type: 'span', _key: keyGenerator(), text: 'baz', marks: []}], + } + + const value = [foo, keyless, bar] as unknown as Array + await createTestEditor({ + keyGenerator, + initialValue: value, + children: ( + { + if (event.type === 'invalid value') { + resolutions.push(event.resolution) + } + }} + /> + ), + }) + await vi.waitFor(() => { + expect(resolutions).toEqual([ + { + patches: [ + { + type: 'set', + path: [1], + value: { + _type: 'block', + _key: 'k7', + children: [{_type: 'span', _key: 'k4', text: 'baz', marks: []}], + }, + }, + ], + description: `Block at index 1 is missing required _key.`, + action: 'Set the block with a random _key value', + item: { + _type: 'block', + children: [{_type: 'span', _key: 'k4', text: 'baz', marks: []}], + }, + i18n: { + description: + 'inputs.portable-text.invalid-value.missing-key.description', + action: 'inputs.portable-text.invalid-value.missing-key.action', + values: {index: 1}, + }, + }, + ]) + }) + expect(applyAll(value, resolutions[0]!.patches)).toEqual([ + { + _type: 'block', + _key: 'k0', + children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], + }, + { + _type: 'block', + _key: 'k7', + children: [{_type: 'span', _key: 'k4', text: 'baz', marks: []}], + }, + { + _type: 'block', + _key: 'k2', + children: [{_type: 'span', _key: 'k3', text: 'bar', marks: []}], + }, + ]) +}) + +test('Scenario: initialValue resolves keyless block last at its value index', async () => { + const keyGenerator = createTestKeyGenerator() + const resolutions: Array = [] + + const foo = { + _type: 'block', + _key: keyGenerator(), + children: [{_type: 'span', _key: keyGenerator(), text: 'foo', marks: []}], + } + const bar = { + _type: 'block', + _key: keyGenerator(), + children: [{_type: 'span', _key: keyGenerator(), text: 'bar', marks: []}], + } + const keyless = { + _type: 'block', + children: [{_type: 'span', _key: keyGenerator(), text: 'baz', marks: []}], + } + + const value = [foo, bar, keyless] as unknown as Array + await createTestEditor({ + keyGenerator, + initialValue: value, + children: ( + { + if (event.type === 'invalid value') { + resolutions.push(event.resolution) + } + }} + /> + ), + }) + await vi.waitFor(() => { + expect(resolutions).toEqual([ + { + patches: [ + { + type: 'set', + path: [2], + value: { + _type: 'block', + _key: 'k7', + children: [{_type: 'span', _key: 'k4', text: 'baz', marks: []}], + }, + }, + ], + description: `Block at index 2 is missing required _key.`, + action: 'Set the block with a random _key value', + item: { + _type: 'block', + children: [{_type: 'span', _key: 'k4', text: 'baz', marks: []}], + }, + i18n: { + description: + 'inputs.portable-text.invalid-value.missing-key.description', + action: 'inputs.portable-text.invalid-value.missing-key.action', + values: {index: 2}, + }, + }, + ]) + }) + expect(applyAll(value, resolutions[0]!.patches)).toEqual([ + { + _type: 'block', + _key: 'k0', + children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], + }, + { + _type: 'block', + _key: 'k2', + children: [{_type: 'span', _key: 'k3', text: 'bar', marks: []}], + }, + { + _type: 'block', + _key: 'k7', + children: [{_type: 'span', _key: 'k4', text: 'baz', marks: []}], + }, + ]) +}) + +test('Scenario: initialValue resolves string first control at its value index', async () => { + const keyGenerator = createTestKeyGenerator() + const resolutions: Array = [] + + const foo = { + _type: 'block', + _key: keyGenerator(), + children: [{_type: 'span', _key: keyGenerator(), text: 'foo', marks: []}], + } + const bar = { + _type: 'block', + _key: keyGenerator(), + children: [{_type: 'span', _key: keyGenerator(), text: 'bar', marks: []}], + } + + const value = ['oops', foo, bar] as unknown as Array + await createTestEditor({ + keyGenerator, + initialValue: value, + children: ( + { + if (event.type === 'invalid value') { + resolutions.push(event.resolution) + } + }} + /> + ), + }) + await vi.waitFor(() => { + expect(resolutions).toEqual([ + { + patches: [{type: 'unset', path: [0]}], + description: 'Block must be an object, got oops', + action: 'Unset invalid item', + item: 'oops', + i18n: { + description: + 'inputs.portable-text.invalid-value.not-an-object.description', + action: 'inputs.portable-text.invalid-value.not-an-object.action', + values: {index: 0}, + }, + }, + ]) + }) + expect(applyAll(value, resolutions[0]!.patches)).toEqual([ + { + _type: 'block', + _key: 'k0', + children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], + }, + { + _type: 'block', + _key: 'k2', + children: [{_type: 'span', _key: 'k3', text: 'bar', marks: []}], + }, + ]) +}) + +test('Scenario: update value resolves string in the middle at its value index', async () => { + const keyGenerator = createTestKeyGenerator() + const resolutions: Array = [] + + const foo = { + _type: 'block', + _key: keyGenerator(), + children: [{_type: 'span', _key: keyGenerator(), text: 'foo', marks: []}], + } + const bar = { + _type: 'block', + _key: keyGenerator(), + children: [{_type: 'span', _key: keyGenerator(), text: 'bar', marks: []}], + } + + const value = [foo, 'oops', bar] as unknown as Array + const {editor} = await createTestEditor({ + keyGenerator, + initialValue: [bar, foo], + children: ( + { + if (event.type === 'invalid value') { + resolutions.push(event.resolution) + } + }} + /> + ), + }) + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value).toEqual([bar, foo]) + }) + editor.send({type: 'update value', value}) + await vi.waitFor(() => { + expect(resolutions).toEqual([ + { + patches: [{type: 'unset', path: [1]}], + description: 'Block must be an object, got oops', + action: 'Unset invalid item', + item: 'oops', + i18n: { + description: + 'inputs.portable-text.invalid-value.not-an-object.description', + action: 'inputs.portable-text.invalid-value.not-an-object.action', + values: {index: 1}, + }, + }, + ]) + }) + expect(applyAll(value, resolutions[0]!.patches)).toEqual([ + { + _type: 'block', + _key: 'k0', + children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], + }, + { + _type: 'block', + _key: 'k2', + children: [{_type: 'span', _key: 'k3', text: 'bar', marks: []}], + }, + ]) +}) + +test('Scenario: update value resolves keyless block in the middle at its value index', async () => { + const keyGenerator = createTestKeyGenerator() + const resolutions: Array = [] + + const foo = { + _type: 'block', + _key: keyGenerator(), + children: [{_type: 'span', _key: keyGenerator(), text: 'foo', marks: []}], + } + const bar = { + _type: 'block', + _key: keyGenerator(), + children: [{_type: 'span', _key: keyGenerator(), text: 'bar', marks: []}], + } + const keyless = { + _type: 'block', + children: [{_type: 'span', _key: keyGenerator(), text: 'baz', marks: []}], + } + + const value = [foo, keyless, bar] as unknown as Array + const {editor} = await createTestEditor({ + keyGenerator, + initialValue: [bar, foo], + children: ( + { + if (event.type === 'invalid value') { + resolutions.push(event.resolution) + } + }} + /> + ), + }) + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value).toEqual([bar, foo]) + }) + editor.send({type: 'update value', value}) + await vi.waitFor(() => { + expect(resolutions).toEqual([ + { + patches: [ + { + type: 'set', + path: [1], + value: { + _type: 'block', + _key: 'k7', + children: [{_type: 'span', _key: 'k4', text: 'baz', marks: []}], + }, + }, + ], + description: `Block at index 1 is missing required _key.`, + action: 'Set the block with a random _key value', + item: { + _type: 'block', + children: [{_type: 'span', _key: 'k4', text: 'baz', marks: []}], + }, + i18n: { + description: + 'inputs.portable-text.invalid-value.missing-key.description', + action: 'inputs.portable-text.invalid-value.missing-key.action', + values: {index: 1}, + }, + }, + ]) + }) + expect(applyAll(value, resolutions[0]!.patches)).toEqual([ + { + _type: 'block', + _key: 'k0', + children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], + }, + { + _type: 'block', + _key: 'k7', + children: [{_type: 'span', _key: 'k4', text: 'baz', marks: []}], + }, + { + _type: 'block', + _key: 'k2', + children: [{_type: 'span', _key: 'k3', text: 'bar', marks: []}], + }, + ]) +}) + +test('Scenario: update value resolves keyless block last at its value index', async () => { + const keyGenerator = createTestKeyGenerator() + const resolutions: Array = [] + + const foo = { + _type: 'block', + _key: keyGenerator(), + children: [{_type: 'span', _key: keyGenerator(), text: 'foo', marks: []}], + } + const bar = { + _type: 'block', + _key: keyGenerator(), + children: [{_type: 'span', _key: keyGenerator(), text: 'bar', marks: []}], + } + const keyless = { + _type: 'block', + children: [{_type: 'span', _key: keyGenerator(), text: 'baz', marks: []}], + } + + const value = [foo, bar, keyless] as unknown as Array + const {editor} = await createTestEditor({ + keyGenerator, + initialValue: [bar, foo], + children: ( + { + if (event.type === 'invalid value') { + resolutions.push(event.resolution) + } + }} + /> + ), + }) + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value).toEqual([bar, foo]) + }) + editor.send({type: 'update value', value}) + await vi.waitFor(() => { + expect(resolutions).toEqual([ + { + patches: [ + { + type: 'set', + path: [2], + value: { + _type: 'block', + _key: 'k7', + children: [{_type: 'span', _key: 'k4', text: 'baz', marks: []}], + }, + }, + ], + description: `Block at index 2 is missing required _key.`, + action: 'Set the block with a random _key value', + item: { + _type: 'block', + children: [{_type: 'span', _key: 'k4', text: 'baz', marks: []}], + }, + i18n: { + description: + 'inputs.portable-text.invalid-value.missing-key.description', + action: 'inputs.portable-text.invalid-value.missing-key.action', + values: {index: 2}, + }, + }, + ]) + }) + expect(applyAll(value, resolutions[0]!.patches)).toEqual([ + { + _type: 'block', + _key: 'k0', + children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], + }, + { + _type: 'block', + _key: 'k2', + children: [{_type: 'span', _key: 'k3', text: 'bar', marks: []}], + }, + { + _type: 'block', + _key: 'k7', + children: [{_type: 'span', _key: 'k4', text: 'baz', marks: []}], + }, + ]) +}) + +test('Scenario: update value resolves string first control at its value index', async () => { + const keyGenerator = createTestKeyGenerator() + const resolutions: Array = [] + + const foo = { + _type: 'block', + _key: keyGenerator(), + children: [{_type: 'span', _key: keyGenerator(), text: 'foo', marks: []}], + } + const bar = { + _type: 'block', + _key: keyGenerator(), + children: [{_type: 'span', _key: keyGenerator(), text: 'bar', marks: []}], + } + + const value = ['oops', foo, bar] as unknown as Array + const {editor} = await createTestEditor({ + keyGenerator, + initialValue: [bar, foo], + children: ( + { + if (event.type === 'invalid value') { + resolutions.push(event.resolution) + } + }} + /> + ), + }) + await vi.waitFor(() => { + expect(editor.getSnapshot().context.value).toEqual([bar, foo]) + }) + editor.send({type: 'update value', value}) + await vi.waitFor(() => { + expect(resolutions).toEqual([ + { + patches: [{type: 'unset', path: [0]}], + description: 'Block must be an object, got oops', + action: 'Unset invalid item', + item: 'oops', + i18n: { + description: + 'inputs.portable-text.invalid-value.not-an-object.description', + action: 'inputs.portable-text.invalid-value.not-an-object.action', + values: {index: 0}, + }, + }, + ]) + }) + expect(applyAll(value, resolutions[0]!.patches)).toEqual([ + { + _type: 'block', + _key: 'k0', + children: [{_type: 'span', _key: 'k1', text: 'foo', marks: []}], + }, + { + _type: 'block', + _key: 'k2', + children: [{_type: 'span', _key: 'k3', text: 'bar', marks: []}], + }, + ]) +}) diff --git a/packages/editor/tests/wire-catalogue.test.tsx b/packages/editor/tests/wire-catalogue.test.tsx index 4ea640ebee..aedf6365ff 100644 --- a/packages/editor/tests/wire-catalogue.test.tsx +++ b/packages/editor/tests/wire-catalogue.test.tsx @@ -150,6 +150,37 @@ describe('wire catalogue', () => { }) }) + test('Scenario: splitting a block with multiple spans with insert.break', async () => { + const keyGenerator = createTestKeyGenerator() + const seed = 'B: fo|o [strong:bar] baz' + const schemaDefinition = defineSchema({decorators: [{name: 'strong'}]}) + + const {editor, patches, schema, compiledSchema, selection} = + await setupScenario({keyGenerator, seed, schemaDefinition}) + + editor.send({type: 'select', at: selection}) + editor.send({type: 'insert.break'}) + + await vi.waitFor(() => { + expect(getTersePt(editor.getSnapshot().context)).toEqual([ + 'fo', + 'o ,bar, baz', + ]) + }) + + await writeCapture({ + scenario: 'block-split-multi-span', + schema, + seed, + actions: [ + 'select {caret at offset 2 on the first span}', + "send {type: 'insert.break'}", + ], + patches, + result: captureResult(compiledSchema, editor), + }) + }) + test('Scenario: merging two blocks with backspace at the boundary', async () => { const keyGenerator = createTestKeyGenerator() const seed = 'B: foo\nB: |bar' diff --git a/packages/repair/README.md b/packages/repair/README.md index a4e50fe788..d9b2131df1 100644 --- a/packages/repair/README.md +++ b/packages/repair/README.md @@ -2,7 +2,7 @@ > Structural corrections for Portable Text values -A Portable Text value can arrive structurally broken: a block without `_type`, a node without `_key`, two siblings sharing a key, a span without `text`, a text block without `children`. Code that walks or patches such a value can't address nodes reliably, so the Portable Text Editor corrects these five flaws before anything else. This package exposes the same rules, so other code that writes Portable Text can make a value structurally sound the same way the editor does. +A Portable Text value can arrive structurally broken: an array entry that isn't an object, a block without `_type`, a node without `_key`, two siblings sharing a key, a span without `text`, a text block without `children`. Code that walks or patches such a value can't address nodes reliably, so the Portable Text Editor corrects these six flaws before anything else. This package exposes the same rules, so other code that writes Portable Text can make a value structurally sound the same way the editor does. ## You might not need this @@ -16,7 +16,7 @@ npm install @portabletext/repair ## Usage -`getStructuralCorrection(context, entry)` looks at one node and returns the first correction it needs, or `undefined` when the node is sound. A correction names the rule that found the flaw in `type` and carries the `patches` that fix it, typed as `@portabletext/patches` patches. Apply the patches in order, then ask again for the same node until no correction is returned, since one node can need more than one. +`getStructuralCorrection(context, entry)` looks at one node and returns the first correction it needs, or `undefined` when the node is sound. A correction names the rule that found the flaw in `type` and carries the `patches` that fix it, typed as `@portabletext/patches` patches. Patches can be `set` or `unset`. Apply them in order. If the node remains, ask again for the same node until no correction is returned, since one node can need more than one. ```ts import {getStructuralCorrection} from '@portabletext/repair' @@ -59,12 +59,15 @@ The entry describes the node and where it sits: The rules run in this order, and the first one that applies wins: -| Rule | Flaw | Correction | -| ------------------ | ----------------------------------------- | ---------------------------------------------------------------------- | -| `missing-type` | A node has no `_type` | The span type inside a text block, the text block type everywhere else | -| `missing-key` | A node's `_key` is missing or empty | A key from `keyGenerator` | -| `duplicate-key` | A later sibling repeats the node's `_key` | A new key for the later sibling, addressed by index | -| `span-text` | A span's `text` isn't a string | `''` | -| `missing-children` | A text block's `children` isn't an array | `[]` | +| Rule | Flaw | Correction | +| ------------------ | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| `non-object` | An array entry is `null`, `undefined`, a string, a number, a boolean or an array | Remove the entry | +| `missing-type` | A node has no `_type` | The span type inside a text block, the text block type everywhere else | +| `missing-key` | A node's `_key` is missing or empty | A key from `keyGenerator` | +| `duplicate-key` | A later sibling repeats the node's `_key` | A new key for the later sibling, addressed by index | +| `span-text` | A span's `text` isn't a string | `''` | +| `missing-children` | A text block's `children` isn't an array | `[]` | Anything else, such as missing `markDefs`, adjacent spans with the same marks or an empty text block, is left to the caller. + +A non-object root returns `undefined`. A removal addresses the entry by its position because it has no key, so the patches are meant for a single writer. diff --git a/packages/repair/src/get-structural-correction.test.ts b/packages/repair/src/get-structural-correction.test.ts index 7d650b06e0..2697f20893 100644 --- a/packages/repair/src/get-structural-correction.test.ts +++ b/packages/repair/src/get-structural-correction.test.ts @@ -5,6 +5,72 @@ import {getStructuralCorrection} from './get-structural-correction' const schema = compileSchema(defineSchema({})) describe(getStructuralCorrection.name, () => { + test.each([ + {node: undefined, name: 'undefined'}, + {node: null, name: 'null'}, + {node: 'string', name: 'a string'}, + {node: 42, name: 'a number'}, + {node: false, name: 'a boolean'}, + {node: [], name: 'an empty array'}, + {node: ['x'], name: 'a non-empty array'}, + ])('removes $name at every array depth', ({node}) => { + const context = {schema, keyGenerator: () => 'generated'} + + expect([ + getStructuralCorrection(context, { + node, + path: [0], + parent: undefined, + siblings: () => [], + }), + getStructuralCorrection(context, { + node, + path: [{_key: 'c0'}, 'content', 0], + parent: { + node: {_type: 'container', _key: 'c0'}, + path: [{_key: 'c0'}], + }, + siblings: () => [], + }), + getStructuralCorrection(context, { + node, + path: [{_key: 'b0'}, 'children', 1], + parent: { + node: {_type: schema.block.name, _key: 'b0', children: []}, + path: [{_key: 'b0'}], + }, + siblings: () => [], + }), + ]).toEqual([ + {type: 'non-object', patches: [{type: 'unset', path: [0]}]}, + { + type: 'non-object', + patches: [{type: 'unset', path: [{_key: 'c0'}, 'content', 0]}], + }, + { + type: 'non-object', + patches: [{type: 'unset', path: [{_key: 'b0'}, 'children', 1]}], + }, + ]) + }) + + test.each([null, 'string', [], ['x']])( + 'does not repair a non-object root', + (node) => { + expect( + getStructuralCorrection( + {schema, keyGenerator: () => 'generated'}, + { + node, + path: [], + parent: undefined, + siblings: () => [], + }, + ), + ).toEqual(undefined) + }, + ) + test('adds a missing type before applying later rules', () => { expect( getStructuralCorrection( diff --git a/packages/repair/src/get-structural-correction.ts b/packages/repair/src/get-structural-correction.ts index d6c57ab0a8..fbbc0e4e5c 100644 --- a/packages/repair/src/get-structural-correction.ts +++ b/packages/repair/src/get-structural-correction.ts @@ -1,6 +1,7 @@ import {repairDuplicateKey} from './rule.duplicate-key' import {repairMissingKey} from './rule.missing-key' import {repairMissingType} from './rule.missing-type' +import {repairNonObject} from './rule.non-object' import {repairSpanText} from './rule.span-text' import {repairMissingTextBlockChildren} from './rule.text-block-children' import type { @@ -18,7 +19,18 @@ export function getStructuralCorrection( context: StructuralContext, entry: StructuralEntry, ): StructuralCorrection | undefined { + if ( + entry.path.length === 0 && + (entry.node === null || + typeof entry.node !== 'object' || + Array.isArray(entry.node)) + ) { + // Later rules read properties from the node, so a non-object root cannot reach them. + return undefined + } + return ( + repairNonObject(entry) ?? repairMissingType(context, entry) ?? repairMissingKey(context, entry) ?? repairDuplicateKey(context, entry) ?? diff --git a/packages/repair/src/rule.non-object.ts b/packages/repair/src/rule.non-object.ts new file mode 100644 index 0000000000..4114c7a207 --- /dev/null +++ b/packages/repair/src/rule.non-object.ts @@ -0,0 +1,18 @@ +import type {CorrectionEntry, StructuralCorrection} from './types' + +export function repairNonObject( + entry: CorrectionEntry, +): StructuralCorrection | undefined { + if ( + entry.node !== null && + typeof entry.node === 'object' && + !Array.isArray(entry.node) + ) { + return undefined + } + + return { + type: 'non-object', + patches: [{type: 'unset', path: entry.path}], + } +} diff --git a/packages/repair/src/types.ts b/packages/repair/src/types.ts index 7d4243b555..df77ab9479 100644 --- a/packages/repair/src/types.ts +++ b/packages/repair/src/types.ts @@ -18,6 +18,7 @@ export type StructuralCorrection = { * @public */ export type StructuralRule = + | 'non-object' | 'missing-type' | 'missing-key' | 'duplicate-key' diff --git a/packages/thumbjar/.gitignore b/packages/thumbjar/.gitignore index aa7df24fbb..1fcb1529f8 100644 --- a/packages/thumbjar/.gitignore +++ b/packages/thumbjar/.gitignore @@ -1,2 +1 @@ -.cache out diff --git a/packages/thumbjar/README.md b/packages/thumbjar/README.md index 29e3734a40..deec0c4609 100644 --- a/packages/thumbjar/README.md +++ b/packages/thumbjar/README.md @@ -100,6 +100,12 @@ Any other failure rejects with its error unchanged: an attempt that fails before `recordInput({getText, field})` runs in the page. It records `beforeinput`, `input` and composition events and installs the `window.__state()` the driver reads after each word. `getText` returns the text to report, for example from an editor's model. `field` names the field the driver types into, a `textarea`, a text `input` (`text`, `search`, `tel`, `url` or `password`) or a `[contenteditable]`. A field's text is its `value` for a `textarea` or an `input` and its `textContent` otherwise. Without `field`, events are recorded on `document` in the capture phase, so the field may render later, and `window.__state()` also reports the text of the first `textarea`, text `input` or `[contenteditable]` as `dom`. With `field`, events are recorded on that element along with its text at each event. Each event is also printed to a `#log` element when the page has one. +`recordInput` also installs `window.__selection()` for the driver to read the field’s text and direction-aware selection in UTF-16 offsets. + +Each event records `type`, `inputType`, `data`, `text` and `selection`. `text` is the field's text when the listener runs, or `null` without a field. `selection` is `{anchor, focus}` in UTF-16 offsets, respecting selection direction, or `null` outside the field. In document mode, the field is resolved through `window.__field()` at each event. + +`beforeinput` and `input` also record `isComposing`, `cancelable` and, when `getTargetRanges` is a function, `targetRanges`: an array of `{start, end}` UTF-16 offsets, with `null` for ranges outside the field and `[]` when there are none. Ranges are read during dispatch. `defaultPrevented` is absent initially and added to the same record by a zero-delay timer after the whole dispatch, including the page's handlers. A failed read records `null` for that field. The log updates when the timer completes. + A page seeds the field and then leaves it to the keyboard. A page that controls it, writing its `value` back from its own state on each input the way a React `value` prop does, restarts Gboard's composition: on a `textarea` that nested 7 of 8 Japanese compositions. The driver waits until `window.__state` exists and the field it types into is in the document, so a page that names no field may render it after `recordInput`. It looks the field up again when it reads its position and when it focuses it, and waits while it is missing, so a field that leaves the document in between and comes back does not fail the attempt. A field that never renders, within 20 checks a second apart for each wait and the attempt's deadline, fails with `page never rendered its field`, a driver fault. The driver then taps the field `recordInput` named, or without one the page's first `textarea`, text `input` or `[contenteditable]`, and focuses it with the caret after its text. @@ -124,7 +130,7 @@ thumbjar serve [--port 8765] thumbjar prepare --keyboard ja|en --page-url [--out-dir out] [--booted ] ``` -1. `fetch-u2-jar` downloads the uiautomator2 server jar into `.cache` and checks its hash, unless `.cache` already holds it with that hash, and logs which: `u2.jar downloaded to ` or `u2.jar already at with its pinned hash, not downloaded`. A failed download names the URL that answered, without its query. `src/device/u2-jar.json` pins the jar's URL and hash. It tries three downloads of at most 45 seconds each, 15 seconds apart, so it ends within 165 seconds. Run it before the emulator boots, so no test pays for it. +1. `fetch-u2-jar` downloads the uiautomator2 server jar into `node_modules/.cache/thumbjar/u2.jar` below the nearest package root above the current working directory and checks its hash, unless that path already holds it with that hash. Set `CACHE_DIR` to put it at `$CACHE_DIR/thumbjar/u2.jar` instead. With no package above the working directory, or a `node_modules` that cannot be written, `fetch-u2-jar` fails and says to run inside a package or set `CACHE_DIR`. It logs which: `u2.jar downloaded to ` or `u2.jar already at with its pinned hash, not downloaded`. A failed download names the URL that answered, without its query. `src/device/u2-jar.json` pins the jar's URL and hash. It tries three downloads of at most 45 seconds each, 15 seconds apart, so it ends within 165 seconds. Run it before the emulator boots, so no test pays for it. 2. `serve` serves a built page directory over HTTP, with one log line per request. 3. `prepare` prepares a booted emulator. It disables Play Store, so Play Store updates no app mid-run, and logs one `self-update block:` line saying whether the disable succeeded and whether Play Store is listed as disabled, never failing the prep over it. It waits out the post-boot ANR storm, turns off system error dialogs, makes Chrome skip its first-run screens, shows the soft keyboard with a hardware keyboard attached, adds the Japanese QWERTY layout for `--keyboard ja` through Gboard's settings screens (the Play image has no root to change the locale), checks that `--page-url` answers and, when it is on `localhost`, reverses its port to the device the way the driver does. A step that breaks prints a `PREP FAILED: ` line and exits 1, apart from a Japanese dictionary that never loaded on this boot, which exits 3, so a caller can retry a fresh emulator for it without reading the reason. A capture that cannot show the load, a failed check after the load, the Japanese setup and the prep's deadline all exit 1. Logs and screen dumps go to `--out-dir`: `phases.log`, `diagnose.txt` with the device, Gboard and Chrome versions, and for Japanese `ja-setup.log` and `ja-setup-*` dumps, `boot-logcat.txt` and `ja-dictionary.log`. @@ -157,7 +163,7 @@ When `deviceProject` is given a `summaryFile`, such as `process.env.GITHUB_STEP_ The driver cuts every stage of its own off at a deadline, so a hang there ends as a logged failure: 1. One `typeOnDevice` call takes at most 520 seconds: two attempts of 180 seconds, each with a 30-second failure dump and 10 seconds to stop its uiautomator2 servers, a 20-second Chrome restart between them and 20 seconds of cleanup after, each stage with 5 seconds to stop. An adb process gets 2 seconds after SIGTERM and 2 more after SIGKILL, inside those 5. One `runOnDevice` call takes at most 636 seconds: the same, with a 30-second read of the page after each failed attempt, a 52-second restart that reads Chrome's pid in 5 seconds before and after the force-stop and polls it for up to 15, and one 9-second `adb get-state`, each with 5 seconds to stop. `src/device/budget.ts` holds the bounds, and the device test timeout of 661 seconds that `deviceProject` sets is taken from the longer call: each test has room for one cleanup carried over from a test before it that timed out. -2. `thumbjar prepare` ends within `prepWorstSeconds(keyboard)` from `thumbjar/vitest`: 632 seconds for English and 889 for Japanese. English runs its stages under `timeout -k 10 622`. They have 600 seconds: the ANR settle starts no new check after 240 seconds and ends within about 285, and the Play Store block takes 20 of them with 2 to stop. Japanese captures the boot first, in 15 seconds with 2 to stop, then runs its stages under `timeout -k 10 862`: the same 600 and the Play Store block, the Japanese setup within 250, the dictionary wait, which ends 360 seconds after the emulator came online, a window those 600 cover, and has 45 more for its last read and 5 to stop, and the check after the load in 180 and 10. The prep has 10 to stop. On every exit, the deadline's included, the prep ends the boot capture's reader and every reader it restarted, SIGKILL after 2 seconds for one that ignores SIGTERM, before it returns. +2. `thumbjar prepare` ends within `prepWorstSeconds(keyboard)` from `thumbjar/vitest`: 632 seconds for English and 889 for Japanese. English runs its stages under a 622-second Node deadline with 10 seconds to stop. They have 600 seconds: the ANR settle starts no new check after 240 seconds and ends within about 285, and the Play Store block takes 20 of them with 2 to stop. Japanese captures the boot first, in 15 seconds with 2 to stop, then runs its stages under an 862-second Node deadline with 10 seconds to stop: the same 600 and the Play Store block, the Japanese setup within 250, the dictionary wait, which ends 360 seconds after the emulator came online, a window those 600 cover, and has 45 more for its last read and 5 to stop, and the check after the load in 180 and 10. The prep has 10 to stop. Before returning, including on its deadline, the prep ends the boot capture's reader and every reader it restarted, SIGKILL after 2 seconds for one that ignores SIGTERM. If the prep is killed before it can clean up, the reader detects its IPC connection closing and stops itself and its adb child. 3. One device test file runs within `deviceTestFileSeconds(testCount)`: 661 seconds for each of its tests, 40 for the global setup, 30 for its device check, 5 to stop that adb and 5 for the page check, and 60 for Vitest's own start and exit, 2083 seconds for a file of three tests. A caller that cuts the run off at it lets a test that times out last still leave the setup file room to fail the file. 4. `thumbjar fetch-u2-jar` ends within `FETCH_U2_JAR_WORST_SECONDS` from `thumbjar/vitest`, 165 seconds: three downloads of 45 seconds, the body included, and 15 seconds before each retry. @@ -179,7 +185,7 @@ The `PHASE` lines in the test's output time each driver step. 3. `src/device/`: the adb, uiautomator2 and CDP clients, the time budget and the helpers the deadlines run on. 4. `src/page.ts`: `recordInput`, and `src/testing.ts`: `runSuite`. 5. `src/vitest/`: `deviceProject`, its global setup, its setup file and the job summary it writes, `summary.ts`. -6. `src/bin.ts` and `src/prepare/`: the `thumbjar` command, the device prep script, the Japanese setup, the page server, the jar download and `diagnose.sh`, which records device facts and never fails. +6. `src/bin.ts` and `src/prepare/`: the `thumbjar` command, the device prep, the Japanese setup, the page server, the jar download and `diagnose.ts`, which records device facts and never fails. 7. `src/fixtures/`: accessibility dumps and event logs from CI runs, and fake-device suites, for the unit tests. Its only runtime dependency is Vitest, whose `afterAll` the setup file uses. adb runs through `child_process`, and CDP goes over the global `WebSocket` and `fetch` through `adb forward`. @@ -205,6 +211,8 @@ From the package containing the Vitest config, with a freshly booted emulator an pnpm exec thumbjar run tests/ja.test.ts --keyboard ja --page-dir android/dist --config vitest.android.config.ts --project device --out-dir android/out ``` +The commands require Node 24 and `adb` on PATH. They have been run on Linux. `run` resolves Vitest from the current working directory’s package and starts it with Node. + `thumbjar run --keyboard ja|en --page-dir --config --project ` serves the page directory, prepares Chrome and Gboard, runs just that file under `deviceTestFileSeconds(countDeviceTests(source))`, and records diagnostics. `--port` defaults to `8765` and `--out-dir` to `out`. Paths are relative to the current working directory; the test source is read relative to the page directory's parent, matching the device project's test root. The output directory holds `timing.txt`, `phases.log`, `http.log`, `vitest.log`, logcat, Chrome exit information and a final screenshot. Failed prep or tests trigger a bounded `adb get-state` check, which records `CHECKED device` or `CHECKED lost` only when adb proves that verdict. A successful test stage records `CHECKED skipped`. Prep failure exits 1; otherwise the command exits with the test stage's status. The HTTP server stops when the command exits. diff --git a/packages/thumbjar/package.config.ts b/packages/thumbjar/package.config.ts index 7d15454191..8122ce8b13 100644 --- a/packages/thumbjar/package.config.ts +++ b/packages/thumbjar/package.config.ts @@ -1,4 +1,3 @@ -import {copyFileSync, mkdirSync} from 'node:fs' import {defineConfig} from '@sanity/pkg-utils' export default defineConfig({ @@ -6,6 +5,17 @@ export default defineConfig({ // Private, so no release tags: nothing renders its API docs. tsdoc: false, bundles: [ + {source: './src/guard.ts', import: './dist/guard.js', runtime: 'node'}, + { + source: './src/prepare/capture.ts', + import: './dist/prepare/capture.js', + runtime: 'node', + }, + { + source: './src/run/logcat.ts', + import: './dist/run/logcat.js', + runtime: 'node', + }, {source: './src/run/cap.ts', import: './dist/run/cap.js', runtime: 'node'}, { source: './src/run/check.ts', @@ -24,18 +34,4 @@ export default defineConfig({ runtime: 'node', }, ], - plugins: [ - { - // `thumbjar prepare` runs `prepare.sh` next to `bin.js`, and it runs `diagnose.sh`, `setup-ja.js` and `dictionary.js` next to itself. - name: 'thumbjar-prepare-scripts', - writeBundle() { - mkdirSync('dist/run', {recursive: true}) - copyFileSync('src/run/run.sh', 'dist/run/run.sh') - mkdirSync('dist/prepare', {recursive: true}) - for (const script of ['prepare.sh', 'diagnose.sh']) { - copyFileSync(`src/prepare/${script}`, `dist/prepare/${script}`) - } - }, - }, - ], }) diff --git a/packages/thumbjar/package.json b/packages/thumbjar/package.json index 01fbf66a26..0d1af9ef3e 100644 --- a/packages/thumbjar/package.json +++ b/packages/thumbjar/package.json @@ -54,9 +54,12 @@ "scripts": { "build": "pkg-utils build --strict --check", "check:types": "tsc --noEmit --pretty --project tsconfig.json && tsc --noEmit --pretty --project tsconfig.page.json", - "clean": "del .turbo && del .cache && del dist && del out && del node_modules", + "clean": "del .turbo && del dist && del out && del node_modules", "test:unit": "vitest run" }, + "dependencies": { + "find-cache-directory": "^6.0.0" + }, "devDependencies": { "@sanity/pkg-utils": "catalog:tooling", "@types/node": "catalog:tooling", diff --git a/packages/thumbjar/src/bin.ts b/packages/thumbjar/src/bin.ts index 91f1b109f0..b29868e15d 100755 --- a/packages/thumbjar/src/bin.ts +++ b/packages/thumbjar/src/bin.ts @@ -2,15 +2,14 @@ // The `thumbjar` command. The library never boots the emulator: run these against one that is booted, // apart from `fetch-u2-jar`, which needs no device. -import {spawn, spawnSync} from 'node:child_process' -import {fileURLToPath} from 'node:url' +import {resolve} from 'node:path' import {parseArgs} from 'node:util' import {localhostPort} from './device/page-url.ts' const USAGE = `Usage: thumbjar fetch-u2-jar - Download the uiautomator2 server jar unless .cache already holds it with its pinned hash, and check its hash, - before the emulator boots. + Download the uiautomator2 server jar unless the calling package's cache already holds it with its pinned hash, + and check its hash before the emulator boots. thumbjar serve [--port 8765] Serve a built page directory over HTTP, one log line per request. thumbjar run --keyboard ja|en --page-dir --config --project [--port 8765] [--out-dir out] @@ -62,45 +61,15 @@ if (command === 'fetch-u2-jar') { ) { usage() } - const script = spawn( - 'bash', - [ - fileURLToPath(new URL('run/run.sh', import.meta.url)), - positionals[0]!, - values.keyboard, - values['page-dir'], - values.config, - values.project, - values.port, - values['out-dir'], - ], - { - stdio: 'inherit', - env: { - ...process.env, - THUMBJAR_RUN_CAP: fileURLToPath( - new URL( - import.meta.url.endsWith('.ts') ? 'run/cap.ts' : 'run/cap.js', - import.meta.url, - ), - ), - THUMBJAR_RUN_CHECK: fileURLToPath( - new URL( - import.meta.url.endsWith('.ts') ? 'run/check.ts' : 'run/check.js', - import.meta.url, - ), - ), - }, - }, - ) - // A signal to this process stops the script too, as it would a script the step ran itself. - for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) { - process.on(signal, () => { - script.kill(signal) - }) - } - script.on('exit', (status) => { - process.exit(status ?? 1) + const {run} = await import('./run/run.ts') + process.exitCode = await run({ + file: positionals[0]!, + keyboard: values.keyboard, + pageDir: resolve(values['page-dir']), + config: values.config, + project: values.project, + port: values.port, + out: resolve(values['out-dir']), }) } else if (command === 'prepare') { const {values} = parseArgs({ @@ -119,21 +88,14 @@ if (command === 'fetch-u2-jar') { ) { usage() } - const result = spawnSync( - fileURLToPath(new URL('prepare/prepare.sh', import.meta.url)), - { - stdio: 'inherit', - env: { - ...process.env, - THUMBJAR_KEYBOARD: values.keyboard, - THUMBJAR_PAGE_URL: values['page-url'], - THUMBJAR_PAGE_PORT: localhostPort(values['page-url']) ?? '', - THUMBJAR_OUT_DIR: values['out-dir'], - THUMBJAR_BOOTED: values.booted ?? String(Math.floor(Date.now() / 1000)), - }, - }, - ) - process.exit(result.status ?? 1) + const {prepare} = await import('./prepare/prepare.ts') + process.exitCode = await prepare({ + keyboard: values.keyboard, + pageUrl: values['page-url'], + port: localhostPort(values['page-url']) ?? '', + out: resolve(values['out-dir']), + booted: Number(values.booted ?? Math.floor(Date.now() / 1000)), + }) } else { usage() } diff --git a/packages/thumbjar/src/device/adb.test.ts b/packages/thumbjar/src/device/adb.test.ts index 1c99414b7b..093d544993 100644 --- a/packages/thumbjar/src/device/adb.test.ts +++ b/packages/thumbjar/src/device/adb.test.ts @@ -1,5 +1,7 @@ +import {ChildProcess} from 'node:child_process' +import * as childProcesses from 'node:child_process' import {EventEmitter} from 'node:events' -import {existsSync, mkdtempSync, readFileSync, rmSync, watch} from 'node:fs' +import {mkdtempSync, rmSync} from 'node:fs' import net from 'node:net' import {tmpdir} from 'node:os' import {join} from 'node:path' @@ -11,6 +13,7 @@ import { deviceVerdict, run, } from './adb.ts' +import {KILL_GRACE_SECONDS} from './budget.ts' import { GUARDED_INSPECTION, runAbortable, @@ -18,6 +21,10 @@ import { UNPRINTABLE_ERROR, } from './util.ts' +vi.mock(import('node:child_process'), async (importOriginal) => ({ + ...(await importOriginal()), +})) + let directory: string beforeEach(() => { @@ -27,19 +34,22 @@ beforeEach(() => { afterEach(() => { rmSync(directory, {recursive: true, force: true}) vi.restoreAllMocks() + vi.useRealTimers() }) describe(run.name, () => { test('an aborted run settles only after a process that ignores SIGTERM has exited', async () => { const ready = join(directory, 'ready') + const child = controlledChild() const controller = new AbortController() const running = runAbortable(controller.signal, () => run(process.execPath, ['-e', ignoreSigterm(ready)], 60), ).catch((error: Error) => error.message) - await fileAppears(ready) - const pid = Number(readFileSync(ready, 'utf8')) + await child.spawned + const pid = child.pid controller.abort(new Error('attempt timed out')) + await child.stop(running) expect({settled: await running, alive: isAlive(pid)}).toEqual({ settled: 'attempt timed out', @@ -49,13 +59,16 @@ describe(run.name, () => { test('a timed-out run settles only after the process has exited', async () => { const ready = join(directory, 'ready') + const child = controlledChild() const running = run( process.execPath, ['-e', ignoreSigterm(ready)], 0.5, ).catch((error: Error) => error.message) - await fileAppears(ready) - const pid = Number(readFileSync(ready, 'utf8')) + await child.spawned + const pid = child.pid + await vi.advanceTimersByTimeAsync(500) + await child.stop(running) expect({settled: await running, alive: isAlive(pid)}).toEqual({ settled: `${process.execPath} -e ${ignoreSigterm(ready)} timed out after 0.5s`, @@ -126,19 +139,42 @@ function ignoreSigterm(ready: string): string { return `process.on('SIGTERM', () => {}); const fs = require('node:fs'); fs.writeFileSync(${JSON.stringify(`${ready}.tmp`)}, String(process.pid)); fs.renameSync(${JSON.stringify(`${ready}.tmp`)}, ${JSON.stringify(ready)}); setInterval(() => {}, 1000)` } -function fileAppears(path: string): Promise { - return new Promise((resolve) => { - const watcher = watch(directory, () => { - if (existsSync(path)) { - watcher.close() - resolve() - } - }) - if (existsSync(path)) { - watcher.close() - resolve() +function controlledChild() { + vi.useFakeTimers() + const child = new ChildProcess() + const spawned = Promise.withResolvers() + const pid = 123456789 + let alive = true + vi.spyOn(childProcesses, 'spawn').mockImplementation(() => { + spawned.resolve() + return child + }) + const kill = vi.spyOn(child, 'kill').mockReturnValue(true) + vi.spyOn(process, 'kill').mockImplementation((target, signal) => { + expect([target, signal]).toEqual([pid, 0]) + if (alive) { + return true } + throw new Error('process exited') }) + return { + pid, + spawned: spawned.promise, + stop: async (running: Promise) => { + let settled = false + void running.then(() => { + settled = true + }) + expect(kill.mock.calls).toEqual([['SIGTERM']]) + await vi.advanceTimersByTimeAsync(KILL_GRACE_SECONDS * 1000) + expect({signals: kill.mock.calls, settled}).toEqual({ + signals: [['SIGTERM'], ['SIGKILL']], + settled: false, + }) + alive = false + child.emit('close', null, 'SIGKILL') + }, + } } function isAlive(pid: number): boolean { diff --git a/packages/thumbjar/src/device/adb.ts b/packages/thumbjar/src/device/adb.ts index 8fae15bf45..9be110795f 100644 --- a/packages/thumbjar/src/device/adb.ts +++ b/packages/thumbjar/src/device/adb.ts @@ -2,8 +2,8 @@ // uiautomator2 connections (device shell and device TCP ports). import {AsyncLocalStorage} from 'node:async_hooks' -import {spawn} from 'node:child_process' import net from 'node:net' +import {runChildProcess} from '../process-child.ts' import {KILL_GRACE_SECONDS} from './budget.ts' import { currentInspection, @@ -104,80 +104,43 @@ export async function run( * `KILL_GRACE_SECONDS`, and rejects once the process has exited. A process still running another * `KILL_GRACE_SECONDS` after SIGKILL rejects with `ProcessNotStoppedError`. */ -function runChild( +async function runChild( file: string, args: string[], timeout: number, output: 'pipe' | 'inherit', ): Promise<{code: number | null; stdout: string; stderr: string}> { const signal = currentSignal() - signal?.throwIfAborted() - return new Promise((resolve, reject) => { - const child = spawn(file, args, {stdio: ['ignore', output, output]}) - const stdout: Buffer[] = [] - const stderr: Buffer[] = [] - child.stdout?.on('data', (chunk: Buffer) => stdout.push(chunk)) - child.stderr?.on('data', (chunk: Buffer) => stderr.push(chunk)) - let stopReason: unknown - const timers: NodeJS.Timeout[] = [] - const stop = (reason: unknown) => { - if (stopReason !== undefined) { - return + const result = await runChildProcess(file, args, { + seconds: timeout, + killAfter: KILL_GRACE_SECONDS, + killWait: KILL_GRACE_SECONDS, + ...(signal === undefined ? {} : {signal}), + output, + reject: (result) => { + if (result.unstopped) { + unstopped.push(`${file} ${args.join(' ')} (pid ${result.pid})`) + return new ProcessNotStoppedError( + `${file} ${args.join(' ')} (pid ${result.pid}) still runs after SIGKILL`, + {cause: result.reason}, + ) } - stopReason = reason - child.kill('SIGTERM') - timers.push( - setTimeout(() => { - child.kill('SIGKILL') - timers.push( - setTimeout(() => { - finish() - unstopped.push(`${file} ${args.join(' ')} (pid ${child.pid})`) - reject( - new ProcessNotStoppedError( - `${file} ${args.join(' ')} (pid ${child.pid}) still runs after SIGKILL`, - {cause: reason}, - ), - ) - }, KILL_GRACE_SECONDS * 1000), - ) - }, KILL_GRACE_SECONDS * 1000), - ) - } - const onAbort = () => stop(signal!.reason) - const finish = () => { - for (const timer of timers) { - clearTimeout(timer) + if (result.reason !== undefined) { + return result.reason } - signal?.removeEventListener('abort', onAbort) - } - timers.push( - setTimeout(() => { - const error = new CommandError( - `${file} ${args.join(' ')} timed out after ${timeout}s`, - ) - error.timedOut = true - stop(error) - }, timeout * 1000), - ) - signal?.addEventListener('abort', onAbort, {once: true}) - child.on('error', (error) => { - finish() - reject(stopReason ?? error) - }) - child.on('close', (code) => { - finish() - if (stopReason !== undefined) { - reject(stopReason) - } else { - resolve({ - code, - stdout: Buffer.concat(stdout).toString('utf8'), - stderr: Buffer.concat(stderr).toString('utf8'), - }) + if (result.error) { + return result.error } - }) + }, + timeoutReason: () => { + const error = new CommandError( + `${file} ${args.join(' ')} timed out after ${timeout}s`, + ) + error.timedOut = true + return error + }, }) + return {code: result.code, stdout: result.stdout, stderr: result.stderr} } export class AdbError extends Error { diff --git a/packages/thumbjar/src/device/budget.test.ts b/packages/thumbjar/src/device/budget.test.ts index 6e38c25d5c..4e759eb573 100644 --- a/packages/thumbjar/src/device/budget.test.ts +++ b/packages/thumbjar/src/device/budget.test.ts @@ -1,5 +1,5 @@ -import {readFileSync} from 'node:fs' import {describe, expect, test} from 'vitest' +import {prepDeadlines} from '../prepare/prepare.ts' import { countDeviceTests, CAPTURE_SEED_SECONDS, @@ -28,11 +28,6 @@ import { U2_JAR_RETRY_SECONDS, } from './budget.ts' -const prepare = readFileSync( - new URL('../prepare/prepare.sh', import.meta.url), - 'utf8', -) - describe(deviceTestFileSeconds.name, () => { test("gives each test its timeout on top of the global setup and Vitest's start and exit", () => { expect([1, 2, 3, 4].map(deviceTestFileSeconds)).toEqual([ @@ -106,36 +101,25 @@ describe(prepWorstSeconds.name, () => { test('runs Japanese prep for 862 seconds after its 17-second boot capture and English prep for 622, each with its kill-after, and each new stage under the caps the prep sums', () => { expect({ - deadlines: /^ {2}ja\) deadline=(\d+) ;;\n {2}\*\) deadline=(\d+) ;;$/m - .exec(prepare) - ?.slice(1) - .map(Number), - prep: /^timeout -k (\d+) "\$deadline" bash -c prep &$/m - .exec(prepare) - ?.slice(1) - .map(Number), - bootCapture: /^\s+timeout -k (\d+) (\d+) bash -c capture_seed /m - .exec(prepare) - ?.slice(1) - .map(Number), - selfUpdateBlock: /^\s+timeout -k (\d+) (\d+) bash -c block_self_update /m - .exec(prepare) - ?.slice(1) - .map(Number), - dictionaryLoad: /^DICTIONARY_LOAD_SECONDS=(\d+)$/m - .exec(prepare) - ?.slice(1) - .map(Number), - dictionaryWait: - /^\s+timeout -k (\d+) \$\(\(wait_seconds \+ (\d+)\)\) node "\$here\/dictionary\.js" wait /m - .exec(prepare) - ?.slice(1) - .map(Number), - dictionaryCheck: - /^\s+timeout -k (\d+) (\d+) node "\$here\/dictionary\.js" check /m - .exec(prepare) - ?.slice(1) - .map(Number), + deadlines: [prepDeadlines.prep.ja, prepDeadlines.prep.en], + prep: [prepDeadlines.prep.killAfter], + bootCapture: [ + prepDeadlines.bootCapture.killAfter, + prepDeadlines.bootCapture.seconds, + ], + selfUpdateBlock: [ + prepDeadlines.selfUpdateBlock.killAfter, + prepDeadlines.selfUpdateBlock.seconds, + ], + dictionaryLoad: [prepDeadlines.dictionaryLoad], + dictionaryWait: [ + prepDeadlines.dictionaryWait.killAfter, + prepDeadlines.dictionaryWait.seconds, + ], + dictionaryCheck: [ + prepDeadlines.dictionaryCheck.killAfter, + prepDeadlines.dictionaryCheck.seconds, + ], }).toEqual({ deadlines: [862, 622], prep: [PREP_KILL_SECONDS], diff --git a/packages/thumbjar/src/device/budget.ts b/packages/thumbjar/src/device/budget.ts index 9f36229e0b..76b0eb9ef0 100644 --- a/packages/thumbjar/src/device/budget.ts +++ b/packages/thumbjar/src/device/budget.ts @@ -190,6 +190,36 @@ export function prepWorstSeconds(keyboard: 'ja' | 'en'): number { ) } +export function prepDeadlineSeconds(keyboard: 'ja' | 'en'): number { + return ( + prepWorstSeconds(keyboard) - + PREP_KILL_SECONDS - + (keyboard === 'ja' ? CAPTURE_SEED_SECONDS + KILL_GRACE_SECONDS : 0) + ) +} + +export const COMMAND_READ_SECONDS = 10 +export const COMMAND_KILL_SECONDS = 5 +export const DIAGNOSTICS_SECONDS = 60 +export const PREP_ADB_SECONDS = 60 +export const PLAY_STORE_COMMAND_SECONDS = 8 +export const PLAY_STORE_COMMAND_KILL_SECONDS = 1 +export const SETTLE_SECONDS = 240 +export const SETTLE_EVENT_SECONDS = 20 +export const SETTLE_CLEAR_SECONDS = 10 +export const SETTLE_POLL_SECONDS = 5 +export const SETTLE_QUIET_SECONDS = 30 +export const JAPANESE_SETUP_SECONDS = 240 +export const JAPANESE_SETUP_KILL_SECONDS = 10 +export const CAPTURE_WAIT_SECONDS = 20 +export const CAPTURE_RETRY_SECONDS = 1 +export const CAPTURE_KILL_WAIT_SECONDS = 1 +export const HTTP_ATTEMPTS = 6 +export const HTTP_ATTEMPT_SECONDS = 5 +export const HTTP_RETRY_SECONDS = 1 +export const RUN_TEST_KILL_SECONDS = 10 +export const RUN_SERVER_KILL_SECONDS = 0 + /** One download of the uiautomator2 server jar, its body included. The slowest seen on CI took 16. */ export const U2_JAR_ATTEMPT_SECONDS = 45 diff --git a/packages/thumbjar/src/device/u2-jar.test.ts b/packages/thumbjar/src/device/u2-jar.test.ts index 31a1edf03d..07cb0cf6ec 100644 --- a/packages/thumbjar/src/device/u2-jar.test.ts +++ b/packages/thumbjar/src/device/u2-jar.test.ts @@ -31,11 +31,79 @@ beforeEach(() => { }) afterEach(() => { + vi.unstubAllEnvs() vi.unstubAllGlobals() + vi.restoreAllMocks() rmSync(directory, {recursive: true, force: true}) }) describe(ensureJar.name, () => { + test('uses the package cache above the calling process cwd by default', async () => { + const packageDirectory = join(directory, 'package') + const cwd = join(packageDirectory, 'nested') + const cachedPath = join( + packageDirectory, + 'node_modules', + '.cache', + 'thumbjar', + 'u2.jar', + ) + mkdirSync(cwd, {recursive: true}) + mkdirSync(join(packageDirectory, 'node_modules', '.cache', 'thumbjar'), { + recursive: true, + }) + writeFileSync(join(packageDirectory, 'package.json'), '{}') + writeFileSync(cachedPath, foo.data) + vi.spyOn(process, 'cwd').mockReturnValue(cwd) + vi.stubGlobal( + 'fetch', + vi.fn(() => { + throw new Error('no download expected') + }), + ) + + await expect(ensureJar({url, sha256: foo.sha256})).resolves.toEqual({ + path: cachedPath, + downloaded: false, + }) + }) + + test('uses `CACHE_DIR` for the default path', async () => { + const cacheDirectory = join(directory, 'cache') + const cachedPath = join(cacheDirectory, 'thumbjar', 'u2.jar') + mkdirSync(join(cacheDirectory, 'thumbjar'), {recursive: true}) + writeFileSync(cachedPath, foo.data) + vi.stubEnv('CACHE_DIR', cacheDirectory) + vi.stubGlobal( + 'fetch', + vi.fn(() => { + throw new Error('no download expected') + }), + ) + + await expect(ensureJar({url, sha256: foo.sha256})).resolves.toEqual({ + path: cachedPath, + downloaded: false, + }) + }) + + test('rejects a default path outside a package', async () => { + const cwd = join(directory, 'not-a-package') + mkdirSync(cwd) + vi.spyOn(process, 'cwd').mockReturnValue(cwd) + vi.stubEnv('CACHE_DIR', '') + vi.stubGlobal( + 'fetch', + vi.fn(() => { + throw new Error('no download expected') + }), + ) + + await expect(ensureJar()).rejects.toThrowError( + `Cannot find a cache directory from cwd ${cwd}; run inside a package or set CACHE_DIR`, + ) + }) + test('returns a jar already at its path with its pinned hash without downloading it', async () => { mkdirSync(join(directory, '.cache')) writeFileSync(path, foo.data) diff --git a/packages/thumbjar/src/device/u2-jar.ts b/packages/thumbjar/src/device/u2-jar.ts index 945659ba7c..f5bfe649a0 100644 --- a/packages/thumbjar/src/device/u2-jar.ts +++ b/packages/thumbjar/src/device/u2-jar.ts @@ -1,23 +1,20 @@ -// The uiautomator2 server jar's download into .cache and its hash check. `u2-jar.json` pins the URL and -// the hash, and the Android IME workflow keys its cache of the jar on that file. +// The uiautomator2 server jar's download into the calling package's cache and its hash check. +// `u2-jar.json` pins the URL and the hash, and the Android IME workflow keys its cache of the jar on +// that file. import {createHash} from 'node:crypto' import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs' -import {dirname} from 'node:path' -import {fileURLToPath} from 'node:url' +import {dirname, join} from 'node:path' +import findCacheDirectory from 'find-cache-directory' import {U2_JAR_ATTEMPT_SECONDS} from './budget.ts' import jar from './u2-jar.json' with {type: 'json'} -const JAR_PATH = fileURLToPath( - new URL('.cache/u2.jar', import.meta.resolve('thumbjar/package.json')), -) - /** - * Download u2.jar into .cache unless it is already there with its pinned hash, and check the hash of a - * download. `downloaded` says which. + * Download u2.jar into the calling package's cache unless it is already there with its pinned hash, + * and check the hash of a download. `downloaded` says which. */ export async function ensureJar({ - path = JAR_PATH, + path = jarPath(), url = jar.url, sha256: expected = jar.sha256, }: {path?: string; url?: string; sha256?: string} = {}): Promise<{ @@ -45,6 +42,17 @@ export async function ensureJar({ return {path, downloaded: true} } +function jarPath(): string { + const cwd = process.cwd() + const cacheDirectory = findCacheDirectory({name: 'thumbjar', cwd}) + if (cacheDirectory === undefined) { + throw new Error( + `Cannot find a cache directory from cwd ${cwd}; run inside a package or set CACHE_DIR`, + ) + } + return join(cacheDirectory, 'u2.jar') +} + function sha256(data: Buffer): string { return createHash('sha256').update(data).digest('hex') } diff --git a/packages/thumbjar/src/fixtures/ja-layouts.xml b/packages/thumbjar/src/fixtures/ja-layouts.xml new file mode 100644 index 0000000000..0baa8c9503 --- /dev/null +++ b/packages/thumbjar/src/fixtures/ja-layouts.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/thumbjar/src/fixtures/ja-search-japanese.xml b/packages/thumbjar/src/fixtures/ja-search-japanese.xml new file mode 100644 index 0000000000..a3a6960fc9 --- /dev/null +++ b/packages/thumbjar/src/fixtures/ja-search-japanese.xml @@ -0,0 +1,321 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/packages/thumbjar/src/fixtures/suites/cleanup-outlives-sigkill.ts b/packages/thumbjar/src/fixtures/suites/cleanup-outlives-sigkill.ts index 9ba5afdb60..e77235ce74 100644 --- a/packages/thumbjar/src/fixtures/suites/cleanup-outlives-sigkill.ts +++ b/packages/thumbjar/src/fixtures/suites/cleanup-outlives-sigkill.ts @@ -10,7 +10,7 @@ test.fails('Scenario: Stopping Chrome after typing leaves an adb process that ou prefix: '', words: [{letters: 'kyou', candidate: '今日'}], }) - await vi.runAllTimersAsync() + await Promise.allSettled([typing, vi.runAllTimersAsync()]) const typed = await typing expect(typed.text).toEqual('今日') diff --git a/packages/thumbjar/src/fixtures/suites/every-outcome.ts b/packages/thumbjar/src/fixtures/suites/every-outcome.ts index 9971fe0b2e..e27f916e18 100644 --- a/packages/thumbjar/src/fixtures/suites/every-outcome.ts +++ b/packages/thumbjar/src/fixtures/suites/every-outcome.ts @@ -16,7 +16,7 @@ beforeEach(() => { test('Scenario: Passed', async (context) => { const running = runOnDevice(context, enterAfterFoo) - await vi.runAllTimersAsync() + await Promise.allSettled([running, vi.runAllTimersAsync()]) const run = await running expect(run.text).toEqual('foo\n') @@ -24,7 +24,7 @@ test('Scenario: Passed', async (context) => { test('Scenario: Failed', async (context) => { const running = runOnDevice(context, enterAfterFoo) - await vi.runAllTimersAsync() + await Promise.allSettled([running, vi.runAllTimersAsync()]) const run = await running expect(run.text).toEqual('foo') @@ -32,7 +32,7 @@ test('Scenario: Failed', async (context) => { test.fails('Scenario: Expected failure', async (context) => { const running = runOnDevice(context, enterAfterFoo) - await vi.runAllTimersAsync() + await Promise.allSettled([running, vi.runAllTimersAsync()]) const run = await running expect(run.text).toEqual('foo') @@ -41,7 +41,7 @@ test.fails('Scenario: Expected failure', async (context) => { test('Scenario: Inconclusive after input', async (context) => { globalThis.gboardDevice.lostReads = [2, 2] const running = runOnDevice(context, enterAfterFoo) - await vi.runAllTimersAsync() + await Promise.allSettled([running, vi.runAllTimersAsync()]) await running }) @@ -53,14 +53,14 @@ test('Scenario: Device offline', async (context) => { ] globalThis.gboardDevice.getState = offline const running = runOnDevice(context, enterAfterFoo) - await vi.runAllTimersAsync() + await Promise.allSettled([running, vi.runAllTimersAsync()]) await running }) test('Scenario: Driver fault', async (context) => { globalThis.gboardDevice.lostReads = [1, 1] const running = runOnDevice(context, enterAfterFoo) - await vi.runAllTimersAsync() + await Promise.allSettled([running, vi.runAllTimersAsync()]) await running }) @@ -70,7 +70,7 @@ test('Scenario: Inconclusive while the device answered', async (context) => { {match: /pidof com\.android\.chrome/, answer: 'hangs'}, ] const running = runOnDevice(context, enterAfterFoo) - await vi.runAllTimersAsync() + await Promise.allSettled([running, vi.runAllTimersAsync()]) await running }) @@ -81,7 +81,7 @@ test('Scenario: Inconclusive while the device was not checked', async (context) ] globalThis.gboardDevice.getState = 'hangs' const running = runOnDevice(context, enterAfterFoo) - await vi.runAllTimersAsync() + await Promise.allSettled([running, vi.runAllTimersAsync()]) await running }) @@ -92,11 +92,11 @@ test('Scenario: Passed without a driver call', () => { test('Scenario: Passed on its last call after a driver fault', async (context) => { globalThis.gboardDevice.lostReads = [1, 1] const faulting = runOnDevice(context, enterAfterFoo).catch(() => undefined) - await vi.runAllTimersAsync() + await Promise.allSettled([faulting, vi.runAllTimersAsync()]) await faulting globalThis.gboardDevice.reset() const running = runOnDevice(context, enterAfterFoo) - await vi.runAllTimersAsync() + await Promise.allSettled([running, vi.runAllTimersAsync()]) const run = await running expect(run.text).toEqual('foo\n') @@ -105,7 +105,7 @@ test('Scenario: Passed on its last call after a driver fault', async (context) = describe('A group', () => { test('Scenario: Passed in a group', async (context) => { const running = runOnDevice(context, enterAfterFoo) - await vi.runAllTimersAsync() + await Promise.allSettled([running, vi.runAllTimersAsync()]) const run = await running expect(run.text).toEqual('foo\n') @@ -113,7 +113,7 @@ describe('A group', () => { test.fails('Scenario: Expected failure in a group', async (context) => { const running = runOnDevice(context, enterAfterFoo) - await vi.runAllTimersAsync() + await Promise.allSettled([running, vi.runAllTimersAsync()]) const run = await running expect(run.text).toEqual('foo') @@ -124,7 +124,7 @@ describe('Another group', () => { test('Scenario: Passed in a group', async (context) => { globalThis.gboardDevice.lostReads = [2, 2] const running = runOnDevice(context, enterAfterFoo) - await vi.runAllTimersAsync() + await Promise.allSettled([running, vi.runAllTimersAsync()]) await running }) }) diff --git a/packages/thumbjar/src/fixtures/suites/failing-run-outcomes.ts b/packages/thumbjar/src/fixtures/suites/failing-run-outcomes.ts index 7c40a7ad06..76848c4bc0 100644 --- a/packages/thumbjar/src/fixtures/suites/failing-run-outcomes.ts +++ b/packages/thumbjar/src/fixtures/suites/failing-run-outcomes.ts @@ -14,7 +14,7 @@ test.fails('Scenario: Backspace and Enter after foo', async (context) => { prefix: 'foo', steps: [{type: 'backspace'}, {type: 'enter'}], }) - await vi.runAllTimersAsync() + await Promise.allSettled([running, vi.runAllTimersAsync()]) const run = await running expect(run.text).toEqual('fo\n') @@ -34,7 +34,7 @@ test.fails('Scenario: Enter after foo', async (context) => { prefix: 'foo', steps: [{type: 'enter'}], }) - await vi.runAllTimersAsync() + await Promise.allSettled([running, vi.runAllTimersAsync()]) const run = await running expect(run.text).toEqual('foo\n') diff --git a/packages/thumbjar/src/fixtures/suites/page-throws-in-empty-block.ts b/packages/thumbjar/src/fixtures/suites/page-throws-in-empty-block.ts index b5f1abc82a..d5ebf718ad 100644 --- a/packages/thumbjar/src/fixtures/suites/page-throws-in-empty-block.ts +++ b/packages/thumbjar/src/fixtures/suites/page-throws-in-empty-block.ts @@ -9,7 +9,7 @@ test.fails('Scenario: The editor throws while kyou is typed into an empty block' prefix: '', words: [{letters: 'kyou'}], }) - await vi.runAllTimersAsync() + await Promise.allSettled([typing, vi.runAllTimersAsync()]) expect((await typing).text).toEqual('kyou ') }) diff --git a/packages/thumbjar/src/gboard/typing.test.ts b/packages/thumbjar/src/gboard/typing.test.ts index 55b4607b50..cf44e39191 100644 --- a/packages/thumbjar/src/gboard/typing.test.ts +++ b/packages/thumbjar/src/gboard/typing.test.ts @@ -665,10 +665,42 @@ describe(typeInChrome.name, () => { { keys: ['F', 'O', 'O', 'Space'], events: [ - {type: 'beforeinput', inputType: 'insertText', data: null, text: ''}, - {type: 'input', inputType: 'insertText', data: null, text: 'f'}, - {type: 'beforeinput', inputType: 'insertText', data: null, text: 'f'}, - {type: 'input', inputType: 'insertText', data: null, text: 'fo'}, + { + type: 'beforeinput', + inputType: 'insertText', + data: null, + text: '', + selection: {anchor: 0, focus: 0}, + isComposing: false, + cancelable: false, + }, + { + type: 'input', + inputType: 'insertText', + data: null, + text: 'f', + selection: {anchor: 1, focus: 1}, + isComposing: false, + cancelable: false, + }, + { + type: 'beforeinput', + inputType: 'insertText', + data: null, + text: 'f', + selection: {anchor: 1, focus: 1}, + isComposing: false, + cancelable: false, + }, + { + type: 'input', + inputType: 'insertText', + data: null, + text: 'fo', + selection: {anchor: 2, focus: 2}, + isComposing: false, + cancelable: false, + }, ], }, ]) @@ -838,12 +870,18 @@ describe(runInChrome.name, () => { inputType: 'insertLineBreak', data: null, text: 'foo', + selection: {anchor: 3, focus: 3}, + isComposing: false, + cancelable: false, }, { type: 'input', inputType: 'insertLineBreak', data: null, text: 'foo\n', + selection: {anchor: 4, focus: 4}, + isComposing: false, + cancelable: false, }, ], }, @@ -856,12 +894,18 @@ describe(runInChrome.name, () => { inputType: 'insertLineBreak', data: null, text: 'foo', + selection: {anchor: 3, focus: 3}, + isComposing: false, + cancelable: false, }, { type: 'input', inputType: 'insertLineBreak', data: null, text: 'foo\n', + selection: {anchor: 4, focus: 4}, + isComposing: false, + cancelable: false, }, ], }, @@ -902,12 +946,18 @@ describe(runInChrome.name, () => { inputType: 'deleteContentBackward', data: null, text: '👍🏽foo', + selection: {anchor: 7, focus: 7}, + isComposing: false, + cancelable: false, }, { type: 'input', inputType: 'deleteContentBackward', data: null, text: '👍🏽fo', + selection: {anchor: 6, focus: 6}, + isComposing: false, + cancelable: false, }, ], }, @@ -924,24 +974,36 @@ describe(runInChrome.name, () => { inputType: 'deleteContentBackward', data: null, text: '👍🏽fo', + selection: {anchor: 6, focus: 6}, + isComposing: false, + cancelable: false, }, { type: 'input', inputType: 'deleteContentBackward', data: null, text: '👍🏽f', + selection: {anchor: 5, focus: 5}, + isComposing: false, + cancelable: false, }, { type: 'beforeinput', inputType: 'deleteContentBackward', data: null, text: '👍🏽f', + selection: {anchor: 5, focus: 5}, + isComposing: false, + cancelable: false, }, { type: 'input', inputType: 'deleteContentBackward', data: null, text: '👍🏽', + selection: {anchor: 4, focus: 4}, + isComposing: false, + cancelable: false, }, ], }, @@ -954,36 +1016,54 @@ describe(runInChrome.name, () => { inputType: 'deleteContentBackward', data: null, text: '👍🏽foo', + selection: {anchor: 7, focus: 7}, + isComposing: false, + cancelable: false, }, { type: 'input', inputType: 'deleteContentBackward', data: null, text: '👍🏽fo', + selection: {anchor: 6, focus: 6}, + isComposing: false, + cancelable: false, }, { type: 'beforeinput', inputType: 'deleteContentBackward', data: null, text: '👍🏽fo', + selection: {anchor: 6, focus: 6}, + isComposing: false, + cancelable: false, }, { type: 'input', inputType: 'deleteContentBackward', data: null, text: '👍🏽f', + selection: {anchor: 5, focus: 5}, + isComposing: false, + cancelable: false, }, { type: 'beforeinput', inputType: 'deleteContentBackward', data: null, text: '👍🏽f', + selection: {anchor: 5, focus: 5}, + isComposing: false, + cancelable: false, }, { type: 'input', inputType: 'deleteContentBackward', data: null, text: '👍🏽', + selection: {anchor: 4, focus: 4}, + isComposing: false, + cancelable: false, }, ], }, @@ -1075,12 +1155,18 @@ describe(runInChrome.name, () => { inputType: 'insertLineBreak', data: null, text: 'foo', + selection: {anchor: 3, focus: 3}, + isComposing: false, + cancelable: false, }, { type: 'input', inputType: 'insertLineBreak', data: null, text: 'foo\n', + selection: {anchor: 4, focus: 4}, + isComposing: false, + cancelable: false, }, ], }, @@ -1093,12 +1179,18 @@ describe(runInChrome.name, () => { inputType: 'insertLineBreak', data: null, text: 'foo', + selection: {anchor: 3, focus: 3}, + isComposing: false, + cancelable: false, }, { type: 'input', inputType: 'insertLineBreak', data: null, text: 'foo\n', + selection: {anchor: 4, focus: 4}, + isComposing: false, + cancelable: false, }, ], }) @@ -1108,7 +1200,7 @@ describe(runInChrome.name, () => { fake.opened = () => loadGboardTextareaPage('foo', 'en-keyboard.xml') let reads = 0 fake.evaluating = (expression) => { - if (expression.includes('selectionDirection')) { + if (expression.includes('window.__selection')) { reads += 1 gboard.inputFromPage(`read ${reads}`) } @@ -1139,18 +1231,27 @@ describe(runInChrome.name, () => { inputType: 'deleteContentBackward', data: null, text: 'foo', + selection: {anchor: 3, focus: 3}, + isComposing: false, + cancelable: false, }, { type: 'input', inputType: 'deleteContentBackward', data: null, text: 'fo', + selection: {anchor: 2, focus: 2}, + isComposing: false, + cancelable: false, }, { type: 'input', inputType: 'insertText', data: 'read 2', text: 'fo', + selection: {anchor: 2, focus: 2}, + isComposing: false, + cancelable: false, }, ], }, @@ -1167,18 +1268,27 @@ describe(runInChrome.name, () => { inputType: 'insertLineBreak', data: null, text: 'fo', + selection: {anchor: 2, focus: 2}, + isComposing: false, + cancelable: false, }, { type: 'input', inputType: 'insertLineBreak', data: null, text: 'fo\n', + selection: {anchor: 3, focus: 3}, + isComposing: false, + cancelable: false, }, { type: 'input', inputType: 'insertText', data: 'read 3', text: 'fo\n', + selection: {anchor: 3, focus: 3}, + isComposing: false, + cancelable: false, }, ], }, @@ -1191,31 +1301,54 @@ describe(runInChrome.name, () => { inputType: 'deleteContentBackward', data: null, text: 'foo', + selection: {anchor: 3, focus: 3}, + isComposing: false, + cancelable: false, }, { type: 'input', inputType: 'deleteContentBackward', data: null, text: 'fo', + selection: {anchor: 2, focus: 2}, + isComposing: false, + cancelable: false, + }, + { + type: 'input', + inputType: 'insertText', + data: 'read 2', + text: 'fo', + selection: {anchor: 2, focus: 2}, + isComposing: false, + cancelable: false, }, - {type: 'input', inputType: 'insertText', data: 'read 2', text: 'fo'}, { type: 'beforeinput', inputType: 'insertLineBreak', data: null, text: 'fo', + selection: {anchor: 2, focus: 2}, + isComposing: false, + cancelable: false, }, { type: 'input', inputType: 'insertLineBreak', data: null, text: 'fo\n', + selection: {anchor: 3, focus: 3}, + isComposing: false, + cancelable: false, }, { type: 'input', inputType: 'insertText', data: 'read 3', text: 'fo\n', + selection: {anchor: 3, focus: 3}, + isComposing: false, + cancelable: false, }, ], }) @@ -1262,12 +1395,18 @@ describe(runInChrome.name, () => { inputType: 'deleteContentBackward', data: null, text: 'foo', + selection: {anchor: 3, focus: 3}, + isComposing: false, + cancelable: false, }, { type: 'input', inputType: 'deleteContentBackward', data: null, text: 'fo', + selection: {anchor: 2, focus: 2}, + isComposing: false, + cancelable: false, }, ], }, diff --git a/packages/thumbjar/src/gboard/typing.ts b/packages/thumbjar/src/gboard/typing.ts index d1229acf74..c7545bcf0c 100644 --- a/packages/thumbjar/src/gboard/typing.ts +++ b/packages/thumbjar/src/gboard/typing.ts @@ -1438,9 +1438,8 @@ async function tapRole( await sleep(0.4) } -// The selection in the field the driver types into, in UTF-16 offsets of the field's text. `null` when -// it is outside the field. -const FIELD_SELECTION = `(() => { const field = typeof window.__field === 'function' ? window.__field() : null; if (!field) return null; if (field instanceof HTMLTextAreaElement || field instanceof HTMLInputElement) { const backward = field.selectionDirection === 'backward'; return {text: field.value, anchor: backward ? field.selectionEnd : field.selectionStart, focus: backward ? field.selectionStart : field.selectionEnd}; } const selection = getSelection(); if (!selection || !selection.rangeCount || !field.contains(selection.anchorNode) || !field.contains(selection.focusNode)) return null; const offset = (node, nodeOffset) => { const range = document.createRange(); range.setStart(field, 0); range.setEnd(node, nodeOffset); return range.toString().length; }; return {text: field.textContent, anchor: offset(selection.anchorNode, selection.anchorOffset), focus: offset(selection.focusNode, selection.focusOffset)}; })()` +// `recordInput` supplies the field's text and selection in UTF-16 offsets, or `null` outside the field. +const FIELD_SELECTION = `(typeof window.__selection === 'function' ? window.__selection() : null)` // The page's `window.__state()`, the field's selection and whether it has focus from one evaluation, so no // event can land between them. diff --git a/packages/thumbjar/src/guard.ts b/packages/thumbjar/src/guard.ts new file mode 100644 index 0000000000..35ab4e698c --- /dev/null +++ b/packages/thumbjar/src/guard.ts @@ -0,0 +1,52 @@ +import {KILL_GRACE_SECONDS} from './device/budget.ts' +import {killGroup, runChildProcess, type ChildOptions} from './process-child.ts' + +if (process.send === undefined) { + process.stderr.write('thumbjar guard must be started by thumbjar\n') + process.exit(2) +} + +const { + command, + args, + seconds, + killAfter, +}: { + command: string + args: string[] + seconds?: number + killAfter: number +} = JSON.parse(process.argv[2]!) +const controller = new AbortController() +const options: ChildOptions = { + ...(seconds === undefined ? {} : {seconds}), + killAfter, + killWait: KILL_GRACE_SECONDS, + signal: controller.signal, + groupPid: process.pid, + output: 'inherit', + release: true, +} +const stop = (grace: number) => { + options.killAfter = Math.min(options.killAfter, grace) + controller.abort() +} +process.on('SIGTERM', () => {}) +process.on('SIGINT', () => {}) +process.on('disconnect', () => stop(killAfter)) +process.on('message', (message: {killAfter: number}) => stop(message.killAfter)) +if (!process.connected) { + stop(killAfter) +} +const result = await runChildProcess(command, args, options) +if (result.error) { + process.stderr.write(`${result.error.message}\n`) +} +// The guard shares its group with the stage so killing the guard also ends descendants holding its pipes. +if (process.connected) { + process.send?.({status: result.status}, () => + killGroup(process.pid, 'SIGKILL'), + ) +} else { + killGroup(process.pid, 'SIGKILL') +} diff --git a/packages/thumbjar/src/page.test.ts b/packages/thumbjar/src/page.test.ts index b2dcdfa7c8..17da5afe97 100644 --- a/packages/thumbjar/src/page.test.ts +++ b/packages/thumbjar/src/page.test.ts @@ -1,8 +1,110 @@ // @vitest-environment jsdom -import {describe, expect, test} from 'vitest' +import {afterEach, describe, expect, test, vi} from 'vitest' import {recordInput} from './page.ts' +afterEach(() => { + vi.useRealTimers() +}) + describe(recordInput.name, () => { + test('reports backward textarea selection through `__selection` in document mode', () => { + document.body.innerHTML = '' + const field = document.querySelector('textarea')! + recordInput({getText: () => null}) + field.setSelectionRange(1, 5, 'backward') + + expect(window.__selection()).toEqual({ + text: 'foo😀bar', + anchor: 5, + focus: 1, + }) + }) + + test('reports contenteditable selection across children through `__selection` in document mode', () => { + document.body.innerHTML = + '
foo😀bar
' + const field = document.querySelector('div')! + recordInput({getText: () => null}) + window + .getSelection()! + .setBaseAndExtent( + field.lastChild!.firstChild!, + 2, + field.firstChild!.firstChild!, + 1, + ) + + expect(window.__selection()).toEqual({ + text: 'foo😀bar', + anchor: 7, + focus: 1, + }) + }) + + test('reports null through `__selection` for selection outside the field in document mode', () => { + document.body.innerHTML = '
foo

bar

' + const outside = document.querySelector('p')! + recordInput({getText: () => null}) + window + .getSelection()! + .setBaseAndExtent(outside.firstChild!, 0, outside.firstChild!, 3) + + expect(window.__selection()).toEqual(null) + }) + + test('reports backward textarea selection through `__selection` in field mode', () => { + document.body.innerHTML = '' + const field = document.querySelector('textarea')! + recordInput({getText: () => null, field}) + field.setSelectionRange(1, 5, 'backward') + + expect(window.__selection()).toEqual({ + text: 'foo😀bar', + anchor: 5, + focus: 1, + }) + }) + + test('reports contenteditable selection across children through `__selection` in field mode', () => { + document.body.innerHTML = + '
foo😀bar
' + const field = document.querySelector('div')! + recordInput({getText: () => null, field}) + window + .getSelection()! + .setBaseAndExtent( + field.lastChild!.firstChild!, + 2, + field.firstChild!.firstChild!, + 1, + ) + + expect(window.__selection()).toEqual({ + text: 'foo😀bar', + anchor: 7, + focus: 1, + }) + }) + + test('reports null through `__selection` for selection outside the field in field mode', () => { + document.body.innerHTML = '
foo

bar

' + const field = document.querySelector('div')! + const outside = document.querySelector('p')! + recordInput({getText: () => null, field}) + window + .getSelection()! + .setBaseAndExtent(outside.firstChild!, 0, outside.firstChild!, 3) + + expect(window.__selection()).toEqual(null) + }) + + test('reports null through `__selection` without a field', () => { + document.body.innerHTML = '' + recordInput({getText: () => null}) + + expect(window.__selection()).toEqual(null) + }) + test('records the value of a textarea the page names', () => { document.body.innerHTML = '
' @@ -17,7 +119,15 @@ describe(recordInput.name, () => { expect(window.__state()).toEqual({ text: 'foo', events: [ - {type: 'input', inputType: 'insertText', data: 'foo', text: 'foo'}, + { + type: 'input', + inputType: 'insertText', + data: 'foo', + text: 'foo', + selection: {anchor: 3, focus: 3}, + isComposing: false, + cancelable: false, + }, ], }) }) @@ -35,7 +145,15 @@ describe(recordInput.name, () => { expect(window.__state()).toEqual({ text: 'bar', events: [ - {type: 'input', inputType: 'insertText', data: 'bar', text: 'bar'}, + { + type: 'input', + inputType: 'insertText', + data: 'bar', + text: 'bar', + selection: {anchor: 3, focus: 3}, + isComposing: false, + cancelable: false, + }, ], }) }) @@ -57,4 +175,609 @@ describe(recordInput.name, () => { expect(window.__state()).toEqual({text: null, dom: 'foo', events: []}) }, ) + + test('records backward textarea selection in document mode', () => { + document.body.innerHTML = '' + const field = document.querySelector('textarea')! + recordInput({getText: () => null}) + field.setSelectionRange(1, 3, 'backward') + field.dispatchEvent( + new CompositionEvent('compositionupdate', {data: 'bar'}), + ) + expect(window.__state()).toEqual({ + text: null, + dom: 'foo', + events: [ + { + type: 'compositionupdate', + inputType: undefined, + data: 'bar', + text: 'foo', + selection: {anchor: 3, focus: 1}, + }, + ], + }) + }) + + test('records backward contenteditable selection in document mode', () => { + document.body.innerHTML = '
foo
' + const field = document.querySelector('div')! + recordInput({getText: () => null}) + window + .getSelection()! + .setBaseAndExtent(field.firstChild!, 3, field.firstChild!, 1) + field.dispatchEvent( + new CompositionEvent('compositionupdate', {data: 'bar'}), + ) + expect(window.__state()).toEqual({ + text: null, + dom: 'foo', + events: [ + { + type: 'compositionupdate', + inputType: undefined, + data: 'bar', + text: 'foo', + selection: {anchor: 3, focus: 1}, + }, + ], + }) + }) + + test('records isComposing in document mode', () => { + document.body.innerHTML = '' + const field = document.querySelector('textarea')! + recordInput({getText: () => null}) + field.setSelectionRange(1, 1) + field.dispatchEvent( + new InputEvent('beforeinput', { + inputType: 'insertText', + data: 'bar', + isComposing: true, + }), + ) + expect(window.__state()).toEqual({ + text: null, + dom: 'foo', + events: [ + { + type: 'beforeinput', + inputType: 'insertText', + data: 'bar', + text: 'foo', + selection: {anchor: 1, focus: 1}, + isComposing: true, + cancelable: false, + }, + ], + }) + }) + + test('records cancelable in document mode', () => { + document.body.innerHTML = '' + const field = document.querySelector('textarea')! + recordInput({getText: () => null}) + field.setSelectionRange(1, 1) + field.dispatchEvent( + new InputEvent('beforeinput', { + inputType: 'insertText', + data: 'bar', + cancelable: true, + }), + ) + expect(window.__state()).toEqual({ + text: null, + dom: 'foo', + events: [ + { + type: 'beforeinput', + inputType: 'insertText', + data: 'bar', + text: 'foo', + selection: {anchor: 1, focus: 1}, + isComposing: false, + cancelable: true, + }, + ], + }) + }) + + test('reads target ranges during dispatch in document mode', () => { + document.body.innerHTML = '
foo

bar

' + const field = document.querySelector('div')! + recordInput({getText: () => null}) + window.getSelection()!.removeAllRanges() + const event = new InputEvent('beforeinput', { + inputType: 'deleteContentBackward', + }) + Object.defineProperty(event, 'getTargetRanges', { + value: () => [ + new StaticRange({ + startContainer: field.firstChild!, + startOffset: 1, + endContainer: field.firstChild!, + endOffset: 3, + }), + new StaticRange({ + startContainer: document.querySelector('p')!.firstChild!, + startOffset: 0, + endContainer: field.firstChild!, + endOffset: 1, + }), + ], + }) + field.dispatchEvent(event) + expect(window.__state()).toEqual({ + text: null, + dom: 'foo', + events: [ + { + type: 'beforeinput', + inputType: 'deleteContentBackward', + data: null, + text: 'foo', + selection: null, + isComposing: false, + cancelable: false, + targetRanges: [{start: 1, end: 3}, null], + }, + ], + }) + }) + test('reads defaultPrevented after later handlers in document mode', () => { + vi.useFakeTimers() + document.body.innerHTML = '
'
+    const field = document.querySelector('textarea')!
+    recordInput({getText: () => null})
+    field.setSelectionRange(0, 0)
+    field.addEventListener('beforeinput', (event) => event.preventDefault())
+    field.dispatchEvent(new InputEvent('beforeinput', {cancelable: true}))
+    expect(window.__state()).toEqual({
+      text: null,
+      dom: 'foo',
+      events: [
+        {
+          type: 'beforeinput',
+          inputType: '',
+          data: null,
+          text: 'foo',
+          selection: {anchor: 0, focus: 0},
+          isComposing: false,
+          cancelable: true,
+        },
+      ],
+    })
+    vi.advanceTimersByTime(0)
+    expect(window.__state()).toEqual({
+      text: null,
+      dom: 'foo',
+      events: [
+        {
+          type: 'beforeinput',
+          inputType: '',
+          data: null,
+          text: 'foo',
+          selection: {anchor: 0, focus: 0},
+          isComposing: false,
+          cancelable: true,
+          defaultPrevented: true,
+        },
+      ],
+    })
+    expect(document.getElementById('log')!.textContent).toEqual(
+      '{"type":"beforeinput","inputType":"","data":null,"text":"foo","selection":{"anchor":0,"focus":0},"isComposing":false,"cancelable":true,"defaultPrevented":true}',
+    )
+  })
+
+  test('records backward textarea selection in field mode', () => {
+    document.body.innerHTML = ''
+    const field = document.querySelector('textarea')!
+    recordInput({getText: () => null, field})
+    field.setSelectionRange(1, 3, 'backward')
+    field.dispatchEvent(
+      new CompositionEvent('compositionupdate', {data: 'bar'}),
+    )
+    expect(window.__state()).toEqual({
+      text: null,
+      events: [
+        {
+          type: 'compositionupdate',
+          inputType: undefined,
+          data: 'bar',
+          text: 'foo',
+          selection: {anchor: 3, focus: 1},
+        },
+      ],
+    })
+  })
+
+  test('records backward contenteditable selection in field mode', () => {
+    document.body.innerHTML = '
foo
' + const field = document.querySelector('div')! + recordInput({getText: () => null, field}) + window + .getSelection()! + .setBaseAndExtent(field.firstChild!, 3, field.firstChild!, 1) + field.dispatchEvent( + new CompositionEvent('compositionupdate', {data: 'bar'}), + ) + expect(window.__state()).toEqual({ + text: null, + events: [ + { + type: 'compositionupdate', + inputType: undefined, + data: 'bar', + text: 'foo', + selection: {anchor: 3, focus: 1}, + }, + ], + }) + }) + + test('records isComposing in field mode', () => { + document.body.innerHTML = '' + const field = document.querySelector('textarea')! + recordInput({getText: () => null, field}) + field.setSelectionRange(1, 1) + field.dispatchEvent( + new InputEvent('beforeinput', { + inputType: 'insertText', + data: 'bar', + isComposing: true, + }), + ) + expect(window.__state()).toEqual({ + text: null, + events: [ + { + type: 'beforeinput', + inputType: 'insertText', + data: 'bar', + text: 'foo', + selection: {anchor: 1, focus: 1}, + isComposing: true, + cancelable: false, + }, + ], + }) + }) + + test('records cancelable in field mode', () => { + document.body.innerHTML = '' + const field = document.querySelector('textarea')! + recordInput({getText: () => null, field}) + field.setSelectionRange(1, 1) + field.dispatchEvent( + new InputEvent('beforeinput', { + inputType: 'insertText', + data: 'bar', + cancelable: true, + }), + ) + expect(window.__state()).toEqual({ + text: null, + events: [ + { + type: 'beforeinput', + inputType: 'insertText', + data: 'bar', + text: 'foo', + selection: {anchor: 1, focus: 1}, + isComposing: false, + cancelable: true, + }, + ], + }) + }) + + test('reads target ranges during dispatch in field mode', () => { + document.body.innerHTML = '
foo

bar

' + const field = document.querySelector('div')! + recordInput({getText: () => null, field}) + window.getSelection()!.removeAllRanges() + const event = new InputEvent('beforeinput', { + inputType: 'deleteContentBackward', + }) + Object.defineProperty(event, 'getTargetRanges', { + value: () => [ + new StaticRange({ + startContainer: field.firstChild!, + startOffset: 1, + endContainer: field.firstChild!, + endOffset: 3, + }), + new StaticRange({ + startContainer: document.querySelector('p')!.firstChild!, + startOffset: 0, + endContainer: field.firstChild!, + endOffset: 1, + }), + ], + }) + field.dispatchEvent(event) + expect(window.__state()).toEqual({ + text: null, + events: [ + { + type: 'beforeinput', + inputType: 'deleteContentBackward', + data: null, + text: 'foo', + selection: null, + isComposing: false, + cancelable: false, + targetRanges: [{start: 1, end: 3}, null], + }, + ], + }) + }) + test('reads defaultPrevented after later handlers in field mode', () => { + vi.useFakeTimers() + document.body.innerHTML = '
'
+    const field = document.querySelector('textarea')!
+    recordInput({getText: () => null, field})
+    field.setSelectionRange(0, 0)
+    field.addEventListener('beforeinput', (event) => event.preventDefault())
+    field.dispatchEvent(new InputEvent('beforeinput', {cancelable: true}))
+    expect(window.__state()).toEqual({
+      text: null,
+      events: [
+        {
+          type: 'beforeinput',
+          inputType: '',
+          data: null,
+          text: 'foo',
+          selection: {anchor: 0, focus: 0},
+          isComposing: false,
+          cancelable: true,
+        },
+      ],
+    })
+    vi.advanceTimersByTime(0)
+    expect(window.__state()).toEqual({
+      text: null,
+      events: [
+        {
+          type: 'beforeinput',
+          inputType: '',
+          data: null,
+          text: 'foo',
+          selection: {anchor: 0, focus: 0},
+          isComposing: false,
+          cancelable: true,
+          defaultPrevented: true,
+        },
+      ],
+    })
+    expect(document.getElementById('log')!.textContent).toEqual(
+      '{"type":"beforeinput","inputType":"","data":null,"text":"foo","selection":{"anchor":0,"focus":0},"isComposing":false,"cancelable":true,"defaultPrevented":true}',
+    )
+  })
+
+  test('resolves document text at event time and records null without a field', () => {
+    document.body.innerHTML = ''
+    recordInput({getText: () => null})
+    document.dispatchEvent(new CompositionEvent('compositionstart', {data: ''}))
+    document.body.innerHTML = ''
+    document.dispatchEvent(
+      new CompositionEvent('compositionupdate', {data: 'bar'}),
+    )
+    expect(window.__state()).toEqual({
+      text: null,
+      dom: 'foo',
+      events: [
+        {
+          type: 'compositionstart',
+          inputType: undefined,
+          data: '',
+          text: null,
+          selection: null,
+        },
+        {
+          type: 'compositionupdate',
+          inputType: undefined,
+          data: 'bar',
+          text: 'foo',
+          selection: {anchor: 0, focus: 0},
+        },
+      ],
+    })
+  })
+  test('records null for a throwing getter and preserves the event', () => {
+    document.body.innerHTML = ''
+    const field = document.querySelector('textarea')!
+    recordInput({getText: () => null, field})
+    const event = new InputEvent('input')
+    Object.defineProperty(event, 'isComposing', {
+      get() {
+        throw new Error('foo')
+      },
+    })
+    Object.defineProperty(event, 'getTargetRanges', {value: () => []})
+    field.dispatchEvent(event)
+    expect(window.__state()).toEqual({
+      text: null,
+      events: [
+        {
+          type: 'input',
+          inputType: '',
+          data: null,
+          text: 'foo',
+          selection: {anchor: 0, focus: 0},
+          isComposing: null,
+          cancelable: false,
+          targetRanges: [],
+        },
+      ],
+    })
+  })
+
+  test('counts UTF-16 offsets across contenteditable children', () => {
+    document.body.innerHTML =
+      '
😀foo
' + const field = document.querySelector('div')! + recordInput({getText: () => null, field}) + window + .getSelection()! + .setBaseAndExtent( + field.lastChild!.firstChild!, + 1, + field.firstChild!.firstChild!, + 0, + ) + field.dispatchEvent(new CompositionEvent('compositionend', {data: 'foo'})) + expect(window.__state()).toEqual({ + text: null, + events: [ + { + type: 'compositionend', + inputType: undefined, + data: 'foo', + text: '😀foo', + selection: {anchor: 3, focus: 0}, + }, + ], + }) + }) + + test('records null when the selection leaves the field', () => { + document.body.innerHTML = '
foo

bar

' + const field = document.querySelector('div')! + recordInput({getText: () => null, field}) + window + .getSelection()! + .setBaseAndExtent( + field.firstChild!, + 1, + document.querySelector('p')!.firstChild!, + 2, + ) + field.dispatchEvent(new CompositionEvent('compositionend', {data: 'foo'})) + expect(window.__state()).toEqual({ + text: null, + events: [ + { + type: 'compositionend', + inputType: undefined, + data: 'foo', + text: 'foo', + selection: null, + }, + ], + }) + }) + + test('isolates throwing reads of text, selection and target ranges', () => { + vi.useFakeTimers() + document.body.innerHTML = '' + const field = document.querySelector('textarea')! + recordInput({getText: () => null, field}) + Object.defineProperty(field, 'value', { + get() { + throw new Error('foo') + }, + }) + Object.defineProperty(field, 'selectionStart', { + get() { + throw new Error('foo') + }, + }) + const event = new InputEvent('beforeinput') + Object.defineProperty(event, 'getTargetRanges', { + get() { + throw new Error('foo') + }, + }) + Object.defineProperty(event, 'cancelable', { + get() { + throw new Error('foo') + }, + }) + Object.defineProperty(event, 'defaultPrevented', { + get() { + throw new Error('foo') + }, + }) + field.dispatchEvent(event) + expect(window.__state()).toEqual({ + text: null, + events: [ + { + type: 'beforeinput', + inputType: '', + data: null, + text: null, + selection: null, + isComposing: false, + cancelable: null, + targetRanges: null, + }, + ], + }) + vi.advanceTimersByTime(0) + expect(window.__state()).toEqual({ + text: null, + events: [ + { + type: 'beforeinput', + inputType: '', + data: null, + text: null, + selection: null, + isComposing: false, + cancelable: null, + targetRanges: null, + defaultPrevented: null, + }, + ], + }) + }) + + test('omits targetRanges when unavailable in document mode', () => { + document.body.innerHTML = '' + const field = document.querySelector('textarea')! + recordInput({getText: () => null}) + const event = new InputEvent('input') + Object.defineProperty(event, 'getTargetRanges', {value: undefined}) + field.dispatchEvent(event) + expect(window.__state()).toEqual({ + text: null, + dom: 'foo', + events: [ + { + type: 'input', + inputType: '', + data: null, + text: 'foo', + selection: {anchor: 0, focus: 0}, + isComposing: false, + cancelable: false, + }, + ], + }) + }) + + test('omits targetRanges when unavailable in field mode', () => { + document.body.innerHTML = '' + const field = document.querySelector('textarea')! + recordInput({getText: () => null, field}) + const event = new InputEvent('input') + Object.defineProperty(event, 'getTargetRanges', {value: undefined}) + field.dispatchEvent(event) + expect(window.__state()).toEqual({ + text: null, + events: [ + { + type: 'input', + inputType: '', + data: null, + text: 'foo', + selection: {anchor: 0, focus: 0}, + isComposing: false, + cancelable: false, + }, + ], + }) + }) }) diff --git a/packages/thumbjar/src/page.ts b/packages/thumbjar/src/page.ts index f068d09001..6c079a33ab 100644 --- a/packages/thumbjar/src/page.ts +++ b/packages/thumbjar/src/page.ts @@ -2,6 +2,12 @@ declare global { interface Window { __state: () => unknown __field: () => HTMLElement | null + /** The field's text and direction-aware selection in UTF-16 offsets, or `null` outside the field. */ + __selection: () => { + text: string | null + anchor: number | null + focus: number | null + } | null } } @@ -34,6 +40,14 @@ const EVENT_TYPES = [ * `value` back from its own state on each input the way a React `value` prop does, restarts Gboard's * composition: it nested 7 of 8 Japanese compositions on a `textarea`. * + * Events record `type`, `inputType`, `data`, `text` and `selection` (`{anchor, focus}` in + * UTF-16 offsets, or `null` outside the field). `text` is `null` without a field; document + * listeners resolve `window.__field()` at event time. `beforeinput` and `input` also record + * `isComposing`, `cancelable` and, when supported, `targetRanges` during dispatch. Each range is + * `{start, end}` in UTF-16 offsets or `null` outside the field; no ranges gives `[]`. + * `defaultPrevented` is absent until a zero-delay timer reads it after the whole dispatch. + * Failed reads record `null` for that field. + * * Each recorded event is also printed to a `#log` element when the page has one. * * @example @@ -65,40 +79,75 @@ export function recordInput({ const events: Array> = [] const log = document.getElementById('log') for (const type of EVENT_TYPES) { - if (field) { - field.addEventListener(type, (event) => { - events.push({ - type, - inputType: event instanceof InputEvent ? event.inputType : undefined, - data: - event instanceof InputEvent || event instanceof CompositionEvent - ? event.data - : undefined, - text: fieldText(field), - }) - printLog(log, events) - }) - } else { - document.addEventListener( + const listener = (event: Event) => { + const currentField = read(() => field ?? window.__field()) + const entry: Record = { type, - (event) => { - events.push({ - type, - inputType: - event instanceof InputEvent ? event.inputType : undefined, - data: - event instanceof InputEvent || event instanceof CompositionEvent - ? event.data - : undefined, - }) + inputType: read(() => + event instanceof InputEvent ? event.inputType : undefined, + ), + data: read(() => + event instanceof InputEvent || event instanceof CompositionEvent + ? event.data + : undefined, + ), + text: read(() => (currentField ? fieldText(currentField) : null)), + selection: read(() => fieldSelection(currentField)), + } + if (type === 'beforeinput' || type === 'input') { + entry.isComposing = read(() => + event instanceof InputEvent ? event.isComposing : undefined, + ) + entry.cancelable = read(() => event.cancelable) + const targetRanges = read(() => { + const getTargetRanges = + event instanceof InputEvent ? event.getTargetRanges : undefined + if (typeof getTargetRanges !== 'function') { + return undefined + } + return Array.from(getTargetRanges.call(event), (range) => + read(() => { + const start = fieldOffset( + currentField, + range.startContainer, + range.startOffset, + ) + const end = fieldOffset( + currentField, + range.endContainer, + range.endOffset, + ) + return start === null || end === null ? null : {start, end} + }), + ) + }) + if (targetRanges !== undefined) { + entry.targetRanges = targetRanges + } + // Trusted events run microtask checkpoints between listeners; a timer waits for the whole dispatch. + setTimeout(() => { + entry.defaultPrevented = read(() => event.defaultPrevented) printLog(log, events) - }, - true, - ) + }, 0) + } + events.push(entry) + printLog(log, events) + } + if (field) { + field.addEventListener(type, listener) + } else { + document.addEventListener(type, listener, true) } } window.__field = () => field ?? document.querySelector(TEXT_FIELD_SELECTOR) + window.__selection = () => { + const currentField = window.__field() + const selection = fieldSelection(currentField) + return currentField && selection + ? {text: fieldText(currentField), ...selection} + : null + } window.__state = field ? () => ({text: getText(), events}) : () => { @@ -111,6 +160,55 @@ export function recordInput({ } } +function read(getValue: () => Value): Value | null { + try { + return getValue() + } catch { + return null + } +} + +function fieldSelection(field: HTMLElement | null) { + if (!field) { + return null + } + if ( + field instanceof HTMLTextAreaElement || + field instanceof HTMLInputElement + ) { + const backward = field.selectionDirection === 'backward' + return { + anchor: backward ? field.selectionEnd : field.selectionStart, + focus: backward ? field.selectionStart : field.selectionEnd, + } + } + const selection = window.getSelection() + if (!selection || !selection.rangeCount) { + return null + } + const anchor = fieldOffset( + field, + selection.anchorNode, + selection.anchorOffset, + ) + const focus = fieldOffset(field, selection.focusNode, selection.focusOffset) + return anchor === null || focus === null ? null : {anchor, focus} +} + +function fieldOffset( + field: HTMLElement | null, + node: Node | null, + offset: number, +): number | null { + if (!field || !node || !field.contains(node)) { + return null + } + const range = document.createRange() + range.setStart(field, 0) + range.setEnd(node, offset) + return range.toString().length +} + function fieldText(field: HTMLElement): string | null { if ( field instanceof HTMLTextAreaElement || diff --git a/packages/thumbjar/src/prepare/capture.ts b/packages/thumbjar/src/prepare/capture.ts new file mode 100644 index 0000000000..e1869247b7 --- /dev/null +++ b/packages/thumbjar/src/prepare/capture.ts @@ -0,0 +1,51 @@ +import {setTimeout as delay} from 'node:timers/promises' +import { + COMMAND_KILL_SECONDS, + CAPTURE_WAIT_SECONDS, + CAPTURE_RETRY_SECONDS, +} from '../device/budget.ts' +import {execute, termination} from '../process.ts' + +const stop = termination() +const disconnected = () => process.kill(process.pid, 'SIGTERM') +process.once('disconnect', disconnected) +if (!process.connected) { + disconnected() +} +try { + while (true) { + stop.signal.throwIfAborted() + process.stdout.write( + `CAPTURE_SEGMENT start ${Math.floor(Date.now() / 1000)}\n`, + ) + await execute('adb', ['logcat', '-v', 'epoch', '-b', 'main'], { + killAfter: COMMAND_KILL_SECONDS, + group: false, + collect: false, + signal: stop.signal, + stdout: (chunk) => process.stdout.write(chunk), + stderr: (chunk) => process.stderr.write(chunk), + }) + await execute('adb', ['wait-for-device'], { + seconds: CAPTURE_WAIT_SECONDS, + killAfter: COMMAND_KILL_SECONDS, + group: false, + collect: false, + signal: stop.signal, + stdout: (chunk) => process.stdout.write(chunk), + stderr: (chunk) => process.stderr.write(chunk), + }) + await delay(CAPTURE_RETRY_SECONDS * 1000, undefined, {signal: stop.signal}) + } +} catch (error) { + if (!stop.signal.aborted) { + throw error + } + process.exitCode = stop.status() +} finally { + if (process.connected) { + process.disconnect() + } + process.off('disconnect', disconnected) + stop.dispose() +} diff --git a/packages/thumbjar/src/prepare/diagnose.sh b/packages/thumbjar/src/prepare/diagnose.sh deleted file mode 100755 index ce79d97a14..0000000000 --- a/packages/thumbjar/src/prepare/diagnose.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash -# Device facts for the report. Never fails the job. -set -u -out=${1:-out} -mkdir -p "$out" -{ - echo "== build"; adb shell getprop ro.build.fingerprint; adb shell getprop ro.build.version.sdk - echo "== locale"; adb shell getprop persist.sys.locale; adb shell settings get system system_locales - echo "== ime list -s"; adb shell ime list -s - echo "== default_input_method"; adb shell settings get secure default_input_method - echo "== enabled_input_methods"; adb shell settings get secure enabled_input_methods - echo "== gboard"; adb shell dumpsys package com.google.android.inputmethod.latin | grep -E 'versionName|versionCode' | head -2 - echo "== chrome"; adb shell dumpsys package com.android.chrome | grep -E 'versionName' | head -1 - echo "== gboard subtypes"; adb shell dumpsys input_method | grep -iE 'subtype|mCurrentSubtype|ja' | head -40 - echo "== adb root"; adb root 2>&1 | head -1 -} 2>&1 | tee "$out/diagnose.txt" diff --git a/packages/thumbjar/src/prepare/diagnose.ts b/packages/thumbjar/src/prepare/diagnose.ts new file mode 100644 index 0000000000..84277ca63e --- /dev/null +++ b/packages/thumbjar/src/prepare/diagnose.ts @@ -0,0 +1,82 @@ +import {appendFileSync, writeFileSync} from 'node:fs' +import {join} from 'node:path' +import {DIAGNOSTICS_SECONDS, COMMAND_KILL_SECONDS} from '../device/budget.ts' +import {execute} from '../process.ts' + +export async function diagnose( + out: string, + signal: AbortSignal, + env?: NodeJS.ProcessEnv, +) { + const file = join(out, 'diagnose.txt') + writeFileSync(file, '') + const print = (text: string) => { + appendFileSync(file, text) + process.stdout.write(text) + } + const adb = async ( + args: string[], + filter?: (text: string) => string, + merge = false, + ) => { + const result = await execute('adb', args, { + seconds: DIAGNOSTICS_SECONDS, + killAfter: COMMAND_KILL_SECONDS, + signal, + ...(env === undefined ? {} : {env}), + merge, + ...(filter ? {} : {stdout: (chunk) => print(chunk.toString())}), + stderr: (chunk) => { + if (!merge) { + print(chunk.toString()) + } + }, + }) + if (filter) { + print(filter(result.stdout)) + } + } + print('== build\n') + await adb(['shell', 'getprop', 'ro.build.fingerprint']) + await adb(['shell', 'getprop', 'ro.build.version.sdk']) + print('== locale\n') + await adb(['shell', 'getprop', 'persist.sys.locale']) + await adb(['shell', 'settings', 'get', 'system', 'system_locales']) + print('== ime list -s\n') + await adb(['shell', 'ime', 'list', '-s']) + print('== default_input_method\n') + await adb(['shell', 'settings', 'get', 'secure', 'default_input_method']) + print('== enabled_input_methods\n') + await adb(['shell', 'settings', 'get', 'secure', 'enabled_input_methods']) + print('== gboard\n') + await adb( + ['shell', 'dumpsys', 'package', 'com.google.android.inputmethod.latin'], + (text) => lines(text, /versionName|versionCode/, 2), + ) + print('== chrome\n') + await adb(['shell', 'dumpsys', 'package', 'com.android.chrome'], (text) => + lines(text, /versionName/, 1), + ) + print('== gboard subtypes\n') + await adb(['shell', 'dumpsys', 'input_method'], (text) => + lines(text, /subtype|mCurrentSubtype|ja/i, 40), + ) + print('== adb root\n') + await adb( + ['root'], + (text) => + text.slice( + 0, + text.indexOf('\n') === -1 ? text.length : text.indexOf('\n') + 1, + ), + true, + ) +} + +function lines(text: string, pattern: RegExp, count: number) { + const selected = text + .split('\n') + .filter((line) => pattern.test(line)) + .slice(0, count) + return selected.length ? `${selected.join('\n')}\n` : '' +} diff --git a/packages/thumbjar/src/prepare/dictionary.ts b/packages/thumbjar/src/prepare/dictionary.ts index a8cf2ba184..d757958aca 100644 --- a/packages/thumbjar/src/prepare/dictionary.ts +++ b/packages/thumbjar/src/prepare/dictionary.ts @@ -31,7 +31,7 @@ import { import {closeChrome} from '../gboard/typing.ts' const GBOARD = 'com.google.android.inputmethod.latin' -// The status `wait` exits with when no load line came by its deadline. `prepare.sh` exits with it too, as +// The status `wait` exits with when no load line came by its deadline. `prepare` exits with it too, as // its `DICTIONARY_NOT_LOADED_STATUS`, keep the two in sync. const NOT_LOADED_STATUS = 3 // Each adb call of the wait, so its last read, the version query included, ends within `DICTIONARY_WAIT_TAIL_SECONDS`. @@ -173,7 +173,7 @@ function log(line: string): void { main().then( () => process.exit(0), (error: unknown) => { - // The first line is the reason `prepare.sh` quotes in its PREP FAILED line. + // The first line is the reason `prepare` quotes in its PREP FAILED line. console.error( `DICTIONARY FAILED: ${error instanceof Error ? error.message : String(error)}`, ) diff --git a/packages/thumbjar/src/prepare/fetch-u2-jar.ts b/packages/thumbjar/src/prepare/fetch-u2-jar.ts index 92b6bf4114..6ec8d6abda 100644 --- a/packages/thumbjar/src/prepare/fetch-u2-jar.ts +++ b/packages/thumbjar/src/prepare/fetch-u2-jar.ts @@ -1,4 +1,5 @@ -// Download the uiautomator2 server jar into .cache before the emulator boots, so no test pays for it. +// Download the uiautomator2 server jar into the calling package's cache before the emulator boots, so +// no test pays for it. import {U2_JAR_ATTEMPTS, U2_JAR_RETRY_SECONDS} from '../device/budget.ts' import {ensureJar} from '../device/u2-jar.ts' diff --git a/packages/thumbjar/src/prepare/http.test.ts b/packages/thumbjar/src/prepare/http.test.ts new file mode 100644 index 0000000000..8b44b9f0e5 --- /dev/null +++ b/packages/thumbjar/src/prepare/http.test.ts @@ -0,0 +1,82 @@ +import {once} from 'node:events' +import {createServer} from 'node:http' +import {expect, test} from 'vitest' +import {httpReady} from './http.ts' + +test('accepts a redirect without following it', async () => { + const paths: string[] = [] + const server = createServer((request, response) => { + paths.push(request.url ?? '') + response.writeHead(302, {location: '/elsewhere'}) + response.end('redirect') + }).listen(0, '127.0.0.1') + await once(server, 'listening') + try { + const address = server.address() + if (address === null || typeof address === 'string') { + throw new Error('missing address') + } + expect( + await httpReady( + `http://127.0.0.1:${address.port}/`, + new AbortController().signal, + ), + ).toEqual(true) + expect(paths).toEqual(['/']) + } finally { + server.closeAllConnections() + server.close() + } +}) + +test('fails HTTP 400 without retrying or waiting for its body', async () => { + const paths: string[] = [] + const server = createServer((request, response) => { + paths.push(request.url ?? '') + response.writeHead(400) + response.flushHeaders() + }).listen(0, '127.0.0.1') + await once(server, 'listening') + try { + const address = server.address() + if (address === null || typeof address === 'string') { + throw new Error('missing address') + } + expect( + await httpReady( + `http://127.0.0.1:${address.port}/`, + new AbortController().signal, + ), + ).toEqual(false) + expect(paths).toEqual(['/']) + } finally { + server.closeAllConnections() + server.close() + } +}) + +test('retries a transient HTTP failure up to six attempts', async () => { + const paths: string[] = [] + const server = createServer((request, response) => { + paths.push(request.url ?? '') + response.writeHead(503) + response.end() + }).listen(0, '127.0.0.1') + await once(server, 'listening') + try { + const address = server.address() + if (address === null || typeof address === 'string') { + throw new Error('missing address') + } + expect( + await httpReady( + `http://127.0.0.1:${address.port}/`, + new AbortController().signal, + ), + ).toEqual(false) + expect(paths).toEqual(['/', '/', '/', '/', '/', '/']) + } finally { + server.closeAllConnections() + server.close() + } +}, 10_000) diff --git a/packages/thumbjar/src/prepare/http.ts b/packages/thumbjar/src/prepare/http.ts new file mode 100644 index 0000000000..4bdeec689d --- /dev/null +++ b/packages/thumbjar/src/prepare/http.ts @@ -0,0 +1,68 @@ +import {request as httpRequest} from 'node:http' +import {request as httpsRequest} from 'node:https' +import {setTimeout as delay} from 'node:timers/promises' +import { + HTTP_ATTEMPTS, + HTTP_ATTEMPT_SECONDS, + HTTP_RETRY_SECONDS, +} from '../device/budget.ts' + +export async function httpReady( + url: string, + signal: AbortSignal, +): Promise { + for (let attempt = 1; attempt <= HTTP_ATTEMPTS; attempt++) { + const deadline = AbortSignal.timeout(HTTP_ATTEMPT_SECONDS * 1000) + const result = await new Promise<{ready: boolean; retry: boolean}>( + (resolve) => { + const request = ( + new URL(url).protocol === 'https:' ? httpsRequest : httpRequest + )( + url, + { + signal: AbortSignal.any([signal, deadline]), + }, + (response) => { + const status = response.statusCode ?? 0 + if (status >= 400) { + response.destroy() + resolve({ + ready: false, + retry: [408, 429, 500, 502, 503, 504].includes(status), + }) + return + } + response.resume() + response.on('end', () => + resolve({ + ready: status < 400, + retry: [408, 429, 500, 502, 503, 504].includes(status), + }), + ) + response.on('error', () => + resolve({ready: false, retry: deadline.aborted}), + ) + }, + ) + request.on('error', (error) => + resolve({ + ready: false, + retry: + deadline.aborted || + ('code' in error && error.code === 'ECONNREFUSED'), + }), + ) + request.end() + }, + ) + signal.throwIfAborted() + if (result.ready) { + return true + } + if (!result.retry || attempt === HTTP_ATTEMPTS) { + return false + } + await delay(HTTP_RETRY_SECONDS * 1000, undefined, {signal}) + } + return false +} diff --git a/packages/thumbjar/src/prepare/open-japanese-layouts.test.ts b/packages/thumbjar/src/prepare/open-japanese-layouts.test.ts new file mode 100644 index 0000000000..515245d59d --- /dev/null +++ b/packages/thumbjar/src/prepare/open-japanese-layouts.test.ts @@ -0,0 +1,161 @@ +import {readFileSync} from 'node:fs' +import {describe, expect, test} from 'vitest' +import {UiObjectNotFoundError, type SelectorFields} from '../device/u2.ts' +import {parseXml} from '../gboard/hierarchy.ts' +import {openJapaneseLayouts} from './open-japanese-layouts.ts' + +describe(openJapaneseLayouts.name, () => { + test('rejects when two row taps leave the search screen open', async () => { + const device = fakeDevice(Infinity) + const snapshots: string[] = [] + + await expect( + openJapaneseLayouts(device, (name) => { + snapshots.push(name) + return Promise.resolve() + }), + ).rejects.toThrow( + 'the Japanese layouts screen did not open after tapping the Japanese row', + ) + expect(device.rowTaps).toEqual(2) + expect(snapshots).toEqual(['layouts-missing']) + }) + + test('opens the layouts screen on the second row tap', async () => { + const device = fakeDevice(2) + const snapshots: string[] = [] + + await expect( + openJapaneseLayouts(device, (name) => { + snapshots.push(name) + return Promise.resolve() + }), + ).resolves.toEqual(undefined) + expect(device.rowTaps).toEqual(2) + expect(snapshots).toEqual([]) + }) + + test('continues when the layouts screen opens before the second row tap resolves', async () => { + const device = fakeDevice( + 2, + false, + new UiObjectNotFoundError('row is gone'), + ) + const snapshots: string[] = [] + + await expect( + openJapaneseLayouts(device, (name) => { + snapshots.push(name) + return Promise.resolve() + }), + ).resolves.toEqual(undefined) + expect(device.rowTaps).toEqual(1) + expect(device.rowClickTimeouts).toEqual([undefined, 1]) + expect(snapshots).toEqual([]) + }) + + test('rethrows other errors from the second row tap', async () => { + const error = new Error('device disconnected') + const device = fakeDevice(2, false, error) + const snapshots: string[] = [] + + await expect( + openJapaneseLayouts(device, (name) => { + snapshots.push(name) + return Promise.resolve() + }), + ).rejects.toEqual(error) + expect(snapshots).toEqual([]) + }) + + test('opens the layouts screen on the first row tap', async () => { + const device = fakeDevice(1) + const snapshots: string[] = [] + + await expect( + openJapaneseLayouts(device, (name) => { + snapshots.push(name) + return Promise.resolve() + }), + ).resolves.toEqual(undefined) + expect(device.rowTaps).toEqual(1) + expect(snapshots).toEqual([]) + }) + + test('leaves unrecognized layout labels to the existing layout check', async () => { + const device = fakeDevice(1, true) + const snapshots: string[] = [] + + await expect( + openJapaneseLayouts(device, (name) => { + snapshots.push(name) + return Promise.resolve() + }), + ).resolves.toEqual(undefined) + expect(device.rowTaps).toEqual(1) + expect(snapshots).toEqual([]) + }) +}) + +function fakeDevice( + opensAfter: number, + unknownLabels = false, + secondClickError?: Error, +) { + const search = readFileSync( + new URL('../fixtures/ja-search-japanese.xml', import.meta.url), + 'utf8', + ) + const layouts = readFileSync( + new URL('../fixtures/ja-layouts.xml', import.meta.url), + 'utf8', + ) + const rowClickTimeouts: Array = [] + const device = { + rowTaps: 0, + rowClickTimeouts, + layoutsOpened: false, + find(fields: SelectorFields) { + const exists = () => + Promise.resolve( + parseXml( + device.layoutsOpened || device.rowTaps >= opensAfter + ? unknownLabels + ? layouts.replace('12 キー', 'Kana') + : layouts + : search, + ).some( + ({attrs}) => + (!fields.resourceId || + attrs['resource-id'] === fields.resourceId) && + (typeof fields.textMatches !== 'string' || + new RegExp(fields.textMatches).test(attrs.text ?? '')) && + (typeof fields.descriptionMatches !== 'string' || + new RegExp(fields.descriptionMatches).test( + attrs['content-desc'] ?? '', + )), + ), + ) + return { + exists, + wait: exists, + click: async (timeout?: number) => { + if (fields.resourceId === 'android:id/title') { + device.rowClickTimeouts.push(timeout) + if (device.rowClickTimeouts.length === 2 && secondClickError) { + device.layoutsOpened = true + throw secondClickError + } + } + if (!(await exists())) { + throw new Error('cannot tap a missing node') + } + if (fields.resourceId === 'android:id/title') { + device.rowTaps++ + } + }, + } + }, + } + return device +} diff --git a/packages/thumbjar/src/prepare/open-japanese-layouts.ts b/packages/thumbjar/src/prepare/open-japanese-layouts.ts new file mode 100644 index 0000000000..d67df04300 --- /dev/null +++ b/packages/thumbjar/src/prepare/open-japanese-layouts.ts @@ -0,0 +1,44 @@ +import {UiObjectNotFoundError, type SelectorFields} from '../device/u2.ts' + +export const TWELVE = '^12 (keys|キー)$' + +type LayoutDevice = { + find(fields: SelectorFields): { + exists(): Promise + wait(seconds: number): Promise + click(timeout?: number): Promise + } +} + +export async function openJapaneseLayouts( + device: LayoutDevice, + snap: (name: string) => Promise, +): Promise { + const row = device.find({ + resourceId: 'android:id/title', + textMatches: '^(Japanese|日本語)$', + }) + const twelve = device.find({descriptionMatches: TWELVE}) + await row.click() + if (await twelve.wait(15)) { + return + } + if (await row.exists()) { + try { + await row.click(1) + } catch (error) { + if (!(error instanceof UiObjectNotFoundError)) { + throw error + } + } + if (await twelve.wait(15)) { + return + } + } + if (await row.exists()) { + await snap('layouts-missing') + throw new Error( + 'the Japanese layouts screen did not open after tapping the Japanese row', + ) + } +} diff --git a/packages/thumbjar/src/prepare/prepare.sh b/packages/thumbjar/src/prepare/prepare.sh deleted file mode 100755 index 3ca3a67367..0000000000 --- a/packages/thumbjar/src/prepare/prepare.sh +++ /dev/null @@ -1,218 +0,0 @@ -#!/usr/bin/env bash -# Prepares a booted emulator for `typeOnDevice`. `thumbjar prepare` runs it with THUMBJAR_KEYBOARD, -# THUMBJAR_OUT_DIR, THUMBJAR_PAGE_URL, THUMBJAR_PAGE_PORT and THUMBJAR_BOOTED set, the port from the -# driver's `localhostPort` and empty when the page is not on localhost, and THUMBJAR_BOOTED the host's epoch -# seconds when the emulator came online. A prep step that breaks exits with a PREP FAILED line naming the -# step, and status 1, apart from a Japanese dictionary that never loaded on this boot, which exits -# DICTIONARY_NOT_LOADED_STATUS, 3, so the caller can tell it from every other failure without reading the -# reason. -# The prep runs under one deadline, `timeout -k 10 "$deadline"`, so it ends within `prepWorstSeconds` of -# its keyboard in `src/device/budget.ts`, 632 seconds for English and 889 for Japanese, its boot capture -# included, however the device hangs. -set -u -lang=$THUMBJAR_KEYBOARD -out=$THUMBJAR_OUT_DIR -page_url=$THUMBJAR_PAGE_URL -port=$THUMBJAR_PAGE_PORT -booted=$THUMBJAR_BOOTED -here=$(cd "$(dirname "$0")" && pwd) -# How long after the emulator came online Gboard gets to load its Japanese dictionary, `DICTIONARY_LOAD_SECONDS` -# in `src/device/budget.ts`. -DICTIONARY_LOAD_SECONDS=360 -# The status `dictionary.js wait` exits with when no load line came by its deadline, `NOT_LOADED_STATUS` in -# `dictionary.ts`. -DICTIONARY_NOT_LOADED_STATUS=3 -mkdir -p "$out" -log() { echo "PHASE $(date +%H:%M:%S) $*" | tee -a "$out/phases.log"; } -a() { timeout -k 5 60 adb "$@"; local rc=$?; [ $rc -eq 124 ] && log "TIMEOUT adb $*"; return $rc; } -fail() { log "PREP FAILED: $*"; exit 1; } -# Fails the prep with the reason the dictionary wait printed, so the caller's summary can quote it, and with -# the wait's own status when the dictionary never loaded. A retry in the same out dir appends to -# `ja-dictionary.log`, whose last line an earlier `tee` may have left without its newline, so only the bytes -# after `earlier`, the log's size before this wait, count, and each of their lines starts where this wait's -# output did. -dictionary_failure() { - local rc=$1 earlier=$2 reason - reason=$(tail -c +$((earlier + 1)) "$out/ja-dictionary.log" 2>/dev/null | grep -m 1 '^DICTIONARY FAILED: ') - [ -n "$reason" ] || fail "japanese dictionary wait gave no reason, see ja-dictionary.log" - log "PREP FAILED: ${reason#DICTIONARY FAILED: } See ja-dictionary.log for the dictionary lines it saw." - [ "$rc" -eq "$DICTIONARY_NOT_LOADED_STATUS" ] && exit "$DICTIONARY_NOT_LOADED_STATUS" - exit 1 -} - -# Gboard's `main` log from this boot, where it logs its dictionary load and session rebuilds, for the Japanese -# dictionary wait. The header ties the file to the boot, so a file from an earlier boot or one left behind never -# passes for this one, and `src/gboard/dictionary.ts` reads only lines from after the boot time. logd's line -# counts for `main` after the seed show whether the buffer had dropped any line since boot. Each reader writes a -# `CAPTURE_SEGMENT` line first, and a reader that adb dropped is started again, dumping the buffer again, so a -# restart on a buffer that had dropped lines the capture never read shows as a capture fault. -capture_seed() { - local file="$out/boot-logcat.txt" boot_id btime - boot_id=$(a shell cat /proc/sys/kernel/random/boot_id | tr -d '\r') - btime=$(a shell 'echo $(date +%s) $(cat /proc/uptime)' | awk '{printf "%d", $1 - $2}') - [ -n "$boot_id" ] && [ -n "$btime" ] || fail "boot capture" - echo "CAPTURE boot_id=$boot_id btime=$btime" > "$file" - # A larger buffer leaves a restarted reader more room to dump again what it missed. - a logcat -G 16M -b main || log "logcat -G failed" - { echo "CAPTURE_SEGMENT seed"; a logcat -d -v epoch -b main; } >> "$file" 2>&1 || fail "boot capture" - a logcat -S -b main | awk '$1 == "Total" && total == "" { total = $2 } $1 == "Now" && now == "" { now = $2 } END { if (total != "" && now != "") print "CAPTURE_STATS main total=" total " now=" now }' >> "$file" -} - -# Reads `main` into the capture until stopped, starting a reader adb dropped again. -capture_loop() { - while :; do - echo "CAPTURE_SEGMENT start $(date +%s)" - adb logcat -v epoch -b main - timeout -k 5 20 adb wait-for-device - sleep 1 - done -} - -# Ends the capture's process group, the loop and its readers, however they treat SIGTERM: TERM, up to 2 -# seconds for the group to empty, then KILL and up to 1 second more for it to empty. The prep calls it once -# the dictionary is checked, and this script again on every exit. -stop_capture() { - [ -n "${reader:-}" ] || return 0 - kill -TERM -- "-$reader" 2>/dev/null - local tick - for tick in 1 2 3 4 5 6 7 8 9 10; do - capture_alive || return 0 - sleep 0.2 - done - kill -KILL -- "-$reader" 2>/dev/null - for tick in 1 2 3 4 5; do - capture_alive || return 0 - sleep 0.2 - done -} - -# Whether a process of the capture's group still runs. A reader this script has not reaped yet is no -# longer running. `setsid` makes the group a session of its own, and `ps -g` selects a session. -capture_alive() { - local state - for state in $(ps -o stat= -g "$reader" 2>/dev/null); do - [ "${state#Z}" = "$state" ] && return 0 - done - return 1 -} - -# Play Store updates Play services, Chrome, WebView and Gboard on its own from about 150 seconds after boot, and -# installing Play services kills Chrome mid-test. Gboard downloads its Japanese dictionary itself, not through -# Play Store, so the prep disables Play Store for the run. A block that fails is logged, never a prep failure. -block_self_update() { - local output rc disable listed="does not list" - output=$(timeout -k 1 8 adb shell pm disable-user --user 0 com.android.vending 2>&1) - rc=$? - output=$(printf '%s' "$output" | tr -d '\r' | tr '\n' ' ') - output=${output% } - if [ $rc -eq 0 ]; then - disable=succeeded - elif [ $rc -eq 124 ] || [ $rc -eq 137 ]; then - disable="failed (no answer within 8s)" - else - disable="failed (exit $rc: $output)" - fi - timeout -k 1 8 adb shell pm list packages -d com.android.vending 2>/dev/null | tr -d '\r' | grep -qx 'package:com.android.vending' && listed=lists - log "self-update block: disabling com.android.vending $disable, pm list packages -d $listed it" -} - -prep() { - # Play services runs a startup storm after boot. Chrome depends on its font provider and gets killed - # when it ANRs, so wait until the system has been free of ANRs for 30s. No iteration starts after 240s, - # so the settle ends within about 285s, and the prep deadline bounds it in any case. - # Late ANRs (system, launcher) still pop a dialog that takes window focus. Suppress the dialogs. - a shell settings put global hide_error_dialogs 1 || fail "hide error dialogs" - timeout -k 2 20 bash -c block_self_update || log "self-update block: ran past 20s" - log "settle start" - quiet=0 - settle_start=$SECONDS - while [ $((SECONDS - settle_start)) -lt 240 ]; do - if timeout -k 5 20 adb logcat -d -b events 2>/dev/null | grep -q "am_anr"; then quiet=0; timeout -k 5 10 adb logcat -b events -c; else quiet=$((quiet + 5)); fi - [ $quiet -ge 30 ] && break - sleep 5 - done - log "settle done after $((SECONDS - settle_start))s quiet=$quiet" - log "diagnose start" - timeout -k 5 60 "$here/diagnose.sh" "$out" || log "diagnose rc=$?" - - # Make Chrome skip its first-run screens. Non-root Chrome reads this file only when it is the debug app. - log "chrome flags" - a shell am set-debug-app --persistent com.android.chrome || fail "chrome debug app" - a shell 'echo "_ --disable-fre --no-default-browser-check --no-first-run --disable-features=SigninPromo" > /data/local/tmp/chrome-command-line' || fail "chrome command line" - a shell cat /data/local/tmp/chrome-command-line - - log "soft keyboard" - a shell settings put secure show_ime_with_hard_keyboard 1 || fail "soft keyboard" - a shell settings get secure show_ime_with_hard_keyboard - - if [ "$lang" = ja ]; then - log "japanese setup" - timeout -k 10 240 node "$here/setup-ja.js" "$out" 2>&1 | tee -a "$out/ja-setup.log" - rc=${PIPESTATUS[0]} - log "japanese setup rc=$rc" - [ "$rc" -eq 0 ] || fail "japanese setup, see ja-setup.log" - - # A Mozc session Gboard creates before its dictionary loads never converts, so wait for the load and - # check that a switch after it converts. A prep that ran past the bound still reads the capture once. - wait_seconds=$((booted + DICTIONARY_LOAD_SECONDS - $(date +%s))) - [ $wait_seconds -gt 0 ] || wait_seconds=0 - log "japanese dictionary wait" - earlier_dictionary=$(cat "$out/ja-dictionary.log" 2>/dev/null | wc -c) - timeout -k 5 $((wait_seconds + 45)) node "$here/dictionary.js" wait "$out" "$booted" 2>&1 | tee -a "$out/ja-dictionary.log" - rc=${PIPESTATUS[0]} - log "japanese dictionary wait rc=$rc" - [ "$rc" -eq 0 ] || dictionary_failure "$rc" "$earlier_dictionary" - log "japanese dictionary check" - timeout -k 10 180 node "$here/dictionary.js" check "$out" "$page_url" 2>&1 | tee -a "$out/ja-dictionary.log" - rc=${PIPESTATUS[0]} - log "japanese dictionary check rc=$rc" - [ "$rc" -eq 0 ] || fail "japanese dictionary check, see ja-dictionary.log" - stop_capture - fi - - log "logcat" - a logcat -c - curl -sf --max-time 5 --retry 5 --retry-connrefused --retry-delay 1 -o /dev/null "$page_url" || fail "http server, see http.log" - log "http ok" - if [ -n "$port" ]; then - a reverse "tcp:$port" "tcp:$port" || fail "adb reverse" - a reverse --list || log "adb reverse --list rc=$?" - fi - # Only `fail` and `dictionary_failure` decide a failed prep's status, never the last command's. - return 0 -} - -# The prep's deadline, `prepWorstSeconds` less its kill-after, and for Japanese less its boot capture, which -# runs before it. -case "$lang" in - ja) deadline=862 ;; - *) deadline=622 ;; -esac - -export -f prep log a fail dictionary_failure capture_seed capture_loop stop_capture capture_alive block_self_update -export lang out page_url port here booted DICTIONARY_LOAD_SECONDS DICTIONARY_NOT_LOADED_STATUS - -if [ "$lang" = ja ]; then - log "boot capture" - timeout -k 2 15 bash -c capture_seed || fail "boot capture ran past 15s or failed" - # The reader runs in a process group of its own, so this script can end it and every reader it started on - # any exit, the prep's deadline included, once that deadline killed the prep. - setsid bash -c capture_loop >> "$out/boot-logcat.txt" 2>&1 & - reader=$! - export reader - trap 'stop_capture; wait "$reader" 2>/dev/null' EXIT -fi -# A prep stopped from outside stops its own stages and, through the EXIT trap, the capture. -trap '[ -n "${prep_pid:-}" ] && kill -TERM "$prep_pid" 2>/dev/null; exit 143' TERM -trap '[ -n "${prep_pid:-}" ] && kill -TERM "$prep_pid" 2>/dev/null; exit 130' INT - -# One deadline for the whole prep. `prep` runs in a subshell, so its `fail` exits only the subshell. It -# runs in the background so a signal to this script interrupts the `wait`. `timeout` exits with the -# subshell's status, and its own are 124 and up, so only a dictionary that never loaded exits 3. -timeout -k 10 "$deadline" bash -c prep & -prep_pid=$! -wait "$prep_pid" -rc=$? -[ $rc -eq 124 ] && log "PREP FAILED: prep ran past ${deadline}s" -[ $rc -eq "$DICTIONARY_NOT_LOADED_STATUS" ] && exit "$DICTIONARY_NOT_LOADED_STATUS" -[ $rc -eq 0 ] || exit 1 diff --git a/packages/thumbjar/src/prepare/prepare.test.ts b/packages/thumbjar/src/prepare/prepare.test.ts index cb4522aa90..7277333138 100644 --- a/packages/thumbjar/src/prepare/prepare.test.ts +++ b/packages/thumbjar/src/prepare/prepare.test.ts @@ -1,20 +1,37 @@ -import {spawn, spawnSync} from 'node:child_process' +import {ChildProcess, spawn, spawnSync} from 'node:child_process' +import * as childProcesses from 'node:child_process' import { chmodSync, - copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, + symlinkSync, + readdirSync, } from 'node:fs' import {tmpdir} from 'node:os' import {join} from 'node:path' +import {setTimeout as delay} from 'node:timers/promises' import {fileURLToPath} from 'node:url' import {describe, expect, test, vi} from 'vitest' +import * as processes from '../process.ts' +import * as prepareModule from './prepare.ts' -const prepareScript = fileURLToPath(new URL('prepare.sh', import.meta.url)) +vi.mock('node:child_process', async (importOriginal) => { + const children = await importOriginal() + return {...children} +}) + +vi.mock('node:timers/promises', async (importOriginal) => { + const timers = await importOriginal() + return {...timers, setTimeout: vi.fn(timers.setTimeout)} +}) + +let readersAtReturn: number[] = [] + +const prepareScript = fileURLToPath(new URL('prepare.ts', import.meta.url)) const realSleep = spawnSync('bash', ['-c', 'command -v sleep'], { encoding: 'utf8', }).stdout.trim() @@ -59,9 +76,154 @@ const SERVE = [ 'adb reverse --list', ] +test('prepares with no timeout, setsid or pnpm on PATH', async () => { + const server = spawn( + process.execPath, + [ + '--input-type=module', + '-e', + ` + import http from 'node:http' + http.createServer((request, response) => response.end('foo')).listen(8765, () => console.log('ready')) + `, + ], + {stdio: ['ignore', 'pipe', 'inherit']}, + ) + try { + await new Promise((resolve) => + server.stdout.once('data', () => resolve()), + ) + expect( + prepareWithProcesses({ + keyboard: 'en', + playStore: 'disabled', + portable: true, + }).status, + ).toEqual(0) + } finally { + server.kill() + } +}, 60_000) + +test('passes caller paths and the inherited environment to the dictionary check', () => { + const directory = mkdtempSync(join(tmpdir(), 'thumbjar-environment-')) + const bin = join(directory, 'bin') + const snapshot = join(directory, 'environment.json') + mkdirSync(bin) + const out = join(directory, 'artifacts/prep') + writeFileSync( + join(bin, 'adb'), + `#!${process.execPath} +const args=process.argv.slice(2).join(' ') +if(args==='shell cat /proc/sys/kernel/random/boot_id')console.log('62cd1ade-9b02-4723-ba9e-2749dda3d074') +if(args==='shell echo $(date +%s) $(cat /proc/uptime)')console.log('1793515008 40.00 31.20') +if(args==='logcat -d -v epoch -b main')console.log('1793514994.898 668 799 I ArtService: foo') +if(args==='logcat -v epoch -b main')setInterval(()=>{},1000) +if(args==='shell pm list packages -d com.android.vending')console.log('package:com.android.vending') +`, + ) + chmodSync(join(bin, 'adb'), 0o755) + const pause = join(directory, 'pause.mjs') + writeFileSync(pause, 'export const setTimeout=async()=>{}\n') + const keys = [ + 'THUMBJAR_OUT_DIR', + 'THUMBJAR_KEYBOARD', + 'THUMBJAR_PAGE_URL', + 'THUMBJAR_PAGE_PORT', + 'THUMBJAR_BOOTED', + 'PATH', + 'HOME', + 'TMPDIR', + 'ANDROID_HOME', + 'ANDROID_SDK_ROOT', + 'ANDROID_SERIAL', + 'ANDROID_ADB_SERVER_HOST', + 'ANDROID_ADB_SERVER_PORT', + ] + const dictionary = `import {writeFileSync} from 'node:fs';if(process.argv[2]==='check')writeFileSync(${JSON.stringify(snapshot)},JSON.stringify({args:process.argv.slice(2),cwd:process.cwd(),env:Object.fromEntries(${JSON.stringify(keys)}.map(key=>[key,process.env[key]??null]))}))` + const loader = join(directory, 'hooks.mjs') + writeFileSync( + loader, + `import {registerHooks} from 'node:module' +registerHooks({resolve(specifier,context,next){if(specifier==='node:timers/promises')return{url:${JSON.stringify(new URL(`file://${pause}`).href)},shortCircuit:true};return next(specifier,context)},load(url,context,next){if(url.endsWith('/prepare/setup-ja.ts'))return{format:'module',shortCircuit:true,source:''};if(url.endsWith('/prepare/dictionary.ts'))return{format:'module',shortCircuit:true,source:${JSON.stringify(dictionary)}};return next(url,context)}}) +`, + ) + const env = { + ...process.env, + PATH: `${bin}:${process.env.PATH}`, + HOME: directory, + TMPDIR: tmpdir(), + ANDROID_HOME: '/sdk', + ANDROID_SDK_ROOT: '/sdk-root', + ANDROID_SERIAL: 'foo', + ANDROID_ADB_SERVER_HOST: '127.0.0.1', + ANDROID_ADB_SERVER_PORT: '5037', + THUMBJAR_OUT_DIR: 'inherited-wrong', + NODE_OPTIONS: `--import=${loader}`, + } + try { + const result = spawnSync( + process.execPath, + [ + fileURLToPath(new URL('../bin.ts', import.meta.url)), + 'prepare', + '--keyboard', + 'ja', + '--page-url', + 'http://localhost:1/editor.html', + '--out-dir', + 'artifacts/prep', + '--booted', + '1793515010', + ], + {cwd: directory, env, encoding: 'utf8'}, + ) + expect(result.status).toEqual(1) + expect(JSON.parse(readFileSync(snapshot, 'utf8'))).toEqual({ + args: ['check', out, 'http://localhost:1/editor.html'], + cwd: directory, + env: { + THUMBJAR_OUT_DIR: out, + THUMBJAR_KEYBOARD: 'ja', + THUMBJAR_PAGE_URL: 'http://localhost:1/editor.html', + THUMBJAR_PAGE_PORT: '1', + THUMBJAR_BOOTED: '1793515010', + PATH: env.PATH, + HOME: directory, + TMPDIR: tmpdir(), + ANDROID_HOME: '/sdk', + ANDROID_SDK_ROOT: '/sdk-root', + ANDROID_SERIAL: 'foo', + ANDROID_ADB_SERVER_HOST: '127.0.0.1', + ANDROID_ADB_SERVER_PORT: '5037', + }, + }) + } finally { + rmSync(directory, {recursive: true, force: true}) + } +}, 30_000) + +test('returns a prep deadline as status 1 when it interrupts the settle pause', () => { + const result = deadlinePrep(false) + expect(result).toEqual({status: 1, failure: 'PREP FAILED: prep ran past 1s'}) +}, 15_000) + +test('returns a prep deadline as status 1 when it interrupts the HTTP retry pause', async () => { + expect(await deadlineAtHttpPause()).toEqual({ + status: 1, + failure: 'PREP FAILED: prep ran past 5s', + reachedHTTP: true, + }) +}, 15_000) + +test('keeps the prep deadline status when its stage needs SIGKILL', () => { + const result = deadlinePrep(true) + expect(result).toEqual({status: 1, failure: 'PREP FAILED: prep ran past 1s'}) +}, 15_000) + describe('prepare.sh', () => { - test('disables Play Store before the settle and logs one line saying the disable succeeded and Play Store is listed as disabled', () => { - expect(prepare({keyboard: 'en', playStore: 'disabled'})).toEqual({ + test('disables Play Store before the settle and logs one line saying the disable succeeded and Play Store is listed as disabled', async () => { + expect(await prepareWithoutProcesses('disabled')).toEqual({ status: 0, calls: [ 'adb shell settings put global hide_error_dialogs 1', @@ -84,8 +246,8 @@ describe('prepare.sh', () => { }) }) - test('logs a failed disable that leaves Play Store enabled, and goes on with the prep', () => { - expect(prepare({keyboard: 'en', playStore: 'refused'})).toEqual({ + test('logs a failed disable that leaves Play Store enabled, and goes on with the prep', async () => { + expect(await prepareWithoutProcesses('refused')).toEqual({ status: 0, calls: [ 'adb shell settings put global hide_error_dialogs 1', @@ -109,7 +271,7 @@ describe('prepare.sh', () => { }) test('goes on with the prep when the disable never answers, with one line saying so', () => { - expect(prepare({keyboard: 'en', playStore: 'hung'})).toEqual({ + expect(prepareWithProcesses({keyboard: 'en', playStore: 'hung'})).toEqual({ status: 0, calls: [ 'adb shell settings put global hide_error_dialogs 1', @@ -132,9 +294,9 @@ describe('prepare.sh', () => { }) }, 30_000) - test('reads no deadline from the environment, so an inherited THUMBJAR_PREP_DEADLINE leaves the prep as it is', () => { + test('reads no deadline from the environment, so an inherited THUMBJAR_PREP_DEADLINE leaves the prep as it is', async () => { expect( - prepare({ + await prepare({ keyboard: 'en', playStore: 'disabled', env: {THUMBJAR_PREP_DEADLINE: 'not-a-duration'}, @@ -162,8 +324,8 @@ describe('prepare.sh', () => { }) }) - test('captures the boot before anything else, then waits for the Japanese dictionary from when the emulator came online and checks it on the page after the Japanese setup, before the prep ends', () => { - expect(prepare({keyboard: 'ja', playStore: 'disabled'})).toEqual({ + test('captures the boot before anything else, then waits for the Japanese dictionary from when the emulator came online and checks it on the page after the Japanese setup, before the prep ends', async () => { + expect(await prepare({keyboard: 'ja', playStore: 'disabled'})).toEqual({ status: 0, calls: [ 'adb shell cat /proc/sys/kernel/random/boot_id', @@ -203,9 +365,13 @@ describe('prepare.sh', () => { }) }) - test('fails the prep with the wait’s own reason, leading with the dictionary that never loaded, exits 3 for it, and neither checks it nor serves the page', () => { + test('fails the prep with the wait’s own reason, leading with the dictionary that never loaded, exits 3 for it, and neither checks it nor serves the page', async () => { expect( - prepare({keyboard: 'ja', playStore: 'disabled', dictionary: 'never'}), + await prepare({ + keyboard: 'ja', + playStore: 'disabled', + dictionary: 'never', + }), ).toEqual({ status: 3, calls: [ @@ -241,9 +407,9 @@ describe('prepare.sh', () => { }) }) - test('fails the prep with exit status 1 when the wait finds a capture that cannot show the load', () => { + test('fails the prep with exit status 1 when the wait finds a capture that cannot show the load', async () => { expect( - prepare({ + await prepare({ keyboard: 'ja', playStore: 'disabled', dictionary: 'capture fault', @@ -283,9 +449,9 @@ describe('prepare.sh', () => { }) }) - test('fails the prep with exit status 1 when Gboard did not convert after the load', () => { + test('fails the prep with exit status 1 when Gboard did not convert after the load', async () => { expect( - prepare({ + await prepare({ keyboard: 'ja', playStore: 'disabled', dictionary: 'check fails', @@ -328,9 +494,9 @@ describe('prepare.sh', () => { }) }) - test('fails the prep with exit status 1 when the Japanese setup failed, even when the setup exited 3', () => { + test('fails the prep with exit status 1 when the Japanese setup failed, even when the setup exited 3', async () => { expect( - prepare({keyboard: 'ja', playStore: 'disabled', setup: 'exits 3'}), + await prepare({keyboard: 'ja', playStore: 'disabled', setup: 'exits 3'}), ).toEqual({ status: 1, calls: [ @@ -365,10 +531,18 @@ describe('prepare.sh', () => { }) describe('prepare.sh exit status', () => { - test('logs the status of `adb reverse --list`, its last command, and never takes it as the prep’s, so its exit 3 neither fails the prep nor reads as a dictionary that never loaded', () => { + test('logs the status of `adb reverse --list`, its last command, and never takes it as the prep’s, so its exit 3 neither fails the prep nor reads as a dictionary that never loaded', async () => { expect([ - prepare({keyboard: 'en', playStore: 'disabled', reverseList: 'exits 3'}), - prepare({keyboard: 'ja', playStore: 'disabled', reverseList: 'exits 3'}), + await prepare({ + keyboard: 'en', + playStore: 'disabled', + reverseList: 'exits 3', + }), + await prepare({ + keyboard: 'ja', + playStore: 'disabled', + reverseList: 'exits 3', + }), ]).toEqual([ { status: 0, @@ -432,15 +606,17 @@ describe('prepare.sh exit status', () => { 'CAPTURE boot_id=62cd1ade-9b02-4723-ba9e-2749dda3d074 btime=1793514968', }, ]) - }) + }, 15_000) }) describe('prepare.sh on the retry, in the out dir of a prep whose dictionary never loaded', () => { - test('quotes the reason its own wait printed, and exits 1 for a capture fault', () => { + test('quotes the reason its own wait printed, and exits 1 for a capture fault', async () => { expect( - prepareTwice( - {keyboard: 'ja', playStore: 'disabled', dictionary: 'never'}, - {keyboard: 'ja', playStore: 'disabled', dictionary: 'capture fault'}, + ( + await prepareTwice( + {keyboard: 'ja', playStore: 'disabled', dictionary: 'never'}, + {keyboard: 'ja', playStore: 'disabled', dictionary: 'capture fault'}, + ) ).map(({status, phases}) => ({status, failure: phases.at(-1)})), ).toEqual([ { @@ -452,17 +628,19 @@ describe('prepare.sh on the retry, in the out dir of a prep whose dictionary nev failure: `PREP FAILED: ${CAPTURE_FAULT} See ja-dictionary.log for the dictionary lines it saw.`, }, ]) - }) + }, 15_000) - test('exits 1 for a wait that exited 3 without a reason of its own, whatever reason the first wait left in `ja-dictionary.log`', () => { + test('exits 1 for a wait that exited 3 without a reason of its own, whatever reason the first wait left in `ja-dictionary.log`', async () => { expect( - prepareTwice( - {keyboard: 'ja', playStore: 'disabled', dictionary: 'never'}, - { - keyboard: 'ja', - playStore: 'disabled', - dictionary: 'exits 3 without reason', - }, + ( + await prepareTwice( + {keyboard: 'ja', playStore: 'disabled', dictionary: 'never'}, + { + keyboard: 'ja', + playStore: 'disabled', + dictionary: 'exits 3 without reason', + }, + ) ).map(({status, phases}) => ({status, failure: phases.at(-1)})), ).toEqual([ { @@ -475,15 +653,14 @@ describe('prepare.sh on the retry, in the out dir of a prep whose dictionary nev 'PREP FAILED: japanese dictionary wait gave no reason, see ja-dictionary.log', }, ]) - }) + }, 15_000) }) describe('prepare.sh on the retry, in an out dir whose `ja-dictionary.log` ends in a line cut off before its newline', () => { - // What a first prep's `tee` left when a write limit stopped it mid-line. const cutOff = `DICTIONARY FAILED: ${NEVER_LOADED.slice(0, 120)}` - test('quotes only the reason its own wait printed, and exits 1 for a capture fault', () => { - const result = prepare({ + test('quotes only the reason its own wait printed, and exits 1 for a capture fault', async () => { + const result = await prepare({ keyboard: 'ja', playStore: 'disabled', dictionary: 'capture fault', @@ -496,8 +673,8 @@ describe('prepare.sh on the retry, in an out dir whose `ja-dictionary.log` ends }) }) - test('exits 1 for a wait that exited 3 without a reason of its own', () => { - const result = prepare({ + test('exits 1 for a wait that exited 3 without a reason of its own', async () => { + const result = await prepare({ keyboard: 'ja', playStore: 'disabled', dictionary: 'exits 3 without reason', @@ -519,6 +696,7 @@ describe('prepare.sh with a boot capture reader that ignores SIGTERM', () => { readersStarted: true, readersLeft: [], }) + expect(readersAtReturn).toEqual([]) }, 30_000) test('stops the reader before it returns from a prep whose dictionary never loaded, and exits 3 for it through the deadline', async () => { @@ -527,6 +705,7 @@ describe('prepare.sh with a boot capture reader that ignores SIGTERM', () => { readersStarted: true, readersLeft: [], }) + expect(readersAtReturn).toEqual([]) }, 30_000) test('stops the reader before it returns from a prep that ran past its deadline', async () => { @@ -535,6 +714,7 @@ describe('prepare.sh with a boot capture reader that ignores SIGTERM', () => { readersStarted: true, readersLeft: [], }) + expect(readersAtReturn).toEqual([]) }, 30_000) test('stops the reader before it exits when the prep is terminated from outside', async () => { @@ -543,20 +723,18 @@ describe('prepare.sh with a boot capture reader that ignores SIGTERM', () => { readersStarted: true, readersLeft: [], }) + expect(readersAtReturn).toEqual([]) + }, 30_000) + test('ends the capture worker and its adb when prepare is SIGKILLed', async () => { + expect(await prepareWithStubbornReader({path: 'killed'})).toEqual({ + status: null, + readersStarted: true, + readersLeft: [], + }) }, 30_000) }) -/** - * Runs `prepare.sh` with fake `adb`, `node`, `curl` and `sleep` that record their calls in order, all but - * the background reader of the boot capture, which runs alongside the prep in no fixed order to its calls. - * `playStore` is how `pm disable-user` answers: it disables Play Store, the device refuses, or it never - * answers. With `dictionary: 'never'` the dictionary wait fails as it did on a real boot, exiting 3 as - * `dictionary.js wait` does for a dictionary that never loaded. With `capture fault` it fails on a capture of - * another boot, with `exits 3 without reason` it exits 3 printing no `DICTIONARY FAILED:` line, with - * `check fails` the check after the load fails, with `setup: 'exits 3'` the Japanese setup exits 3, and with - * `reverseList: 'exits 3'` the prep's last command, `adb reverse --list`, exits 3. - */ -function prepare(options: PrepareOptions) { +function prepareWithProcesses(options: PrepareOptions) { const directory = mkdtempSync(join(tmpdir(), 'thumbjar-prepare-')) try { return prepareIn(directory, options) @@ -566,10 +744,10 @@ function prepare(options: PrepareOptions) { } /** Runs `prepare` twice in one directory, so the second prep finds the first one's `out`, as a retry does. */ -function prepareTwice(first: PrepareOptions, second: PrepareOptions) { +async function prepareTwice(first: PrepareOptions, second: PrepareOptions) { const directory = mkdtempSync(join(tmpdir(), 'thumbjar-prepare-')) try { - return [prepareIn(directory, first), prepareIn(directory, second)] + return [await prepare(first, directory), await prepare(second, directory)] } finally { rmSync(directory, {recursive: true, force: true}) } @@ -577,6 +755,7 @@ function prepareTwice(first: PrepareOptions, second: PrepareOptions) { type PrepareOptions = { keyboard: 'en' | 'ja' + portable?: boolean playStore: 'disabled' | 'refused' | 'hung' dictionary?: | 'loaded' @@ -645,26 +824,52 @@ esac exit 0 `, ) - writeFileSync(join(bin, 'curl'), '#!/usr/bin/env bash\nexit 0\n') - writeFileSync(join(bin, 'sleep'), '#!/usr/bin/env bash\nexit 0\n') - for (const name of ['adb', 'node', 'curl', 'sleep']) { + for (const name of ['adb', 'node']) { chmodSync(join(bin, name), 0o755) } - const result = spawnSync(prepareScript, { - cwd: directory, - encoding: 'utf8', - env: { - ...process.env, - PATH: `${bin}:${process.env.PATH}`, - THUMBJAR_KEYBOARD: options.keyboard, - THUMBJAR_OUT_DIR: 'out', - THUMBJAR_PAGE_URL: 'http://localhost:8765/editor.html', - THUMBJAR_PAGE_PORT: '8765', - THUMBJAR_BOOTED: '1793515010', - ...options.env, + if (options.portable) { + for (const name of readdirSync('/usr/bin')) { + if ( + !['timeout', 'setsid', 'pnpm', 'adb', 'node', 'curl', 'sleep'].includes( + name, + ) + ) { + symlinkSync(join('/usr/bin', name), join(bin, name)) + } + } + } + const result = spawnSync( + process.execPath, + options.portable + ? [ + fileURLToPath(new URL('../bin.ts', import.meta.url)), + 'prepare', + '--keyboard', + options.keyboard, + '--page-url', + 'http://localhost:8765/editor.html', + '--out-dir', + 'out', + '--booted', + '1793515010', + ] + : [scriptFor('passes', directory)], + { + cwd: directory, + encoding: 'utf8', + env: { + ...process.env, + PATH: options.portable ? bin : `${bin}:${process.env.PATH}`, + THUMBJAR_KEYBOARD: options.keyboard, + THUMBJAR_OUT_DIR: 'out', + THUMBJAR_PAGE_URL: 'http://localhost:8765/editor.html', + THUMBJAR_PAGE_PORT: '8765', + THUMBJAR_BOOTED: '1793515010', + ...options.env, + }, }, - }) + ) const capture = join(directory, 'out/boot-logcat.txt') return { status: result.status, @@ -691,17 +896,10 @@ exit 0 } } -/** - * Runs a Japanese `prepare.sh` whose boot capture reader, the background `adb logcat`, records its pid and - * ignores SIGTERM. `path` picks how the prep ends: it passes, the dictionary wait fails, the dictionary wait - * hangs past a 3-second deadline, or the prep gets SIGTERM while the wait hangs. Returns the prep's exit - * status and the readers still alive once it ended. The deadline path runs a copy of `prepare.sh` whose - * Japanese deadline is cut to 3 seconds, so the shipped prep reads no deadline of the test's. - */ async function prepareWithStubbornReader({ path, }: { - path: 'passes' | 'never loads' | 'runs past' | 'terminated' + path: 'passes' | 'never loads' | 'runs past' | 'terminated' | 'killed' }) { const directory = mkdtempSync(join(tmpdir(), 'thumbjar-prepare-reader-')) const readers = join(directory, 'readers') @@ -716,6 +914,7 @@ case "$*" in "logcat -v epoch -b main") trap '' TERM echo $$ >> ${readers} + echo $PPID >> ${join(directory, 'capture-pids')} while :; do ${realSleep} 0.2; done ;; "shell cat /proc/sys/kernel/random/boot_id") echo 62cd1ade-9b02-4723-ba9e-2749dda3d074 ;; @@ -744,17 +943,11 @@ esac exit 0 `, ) - writeFileSync(join(bin, 'curl'), '#!/usr/bin/env bash\nexit 0\n') - // Skips the settle's pauses but keeps the capture's own sub-second grace, which the cleanup waits on. - writeFileSync( - join(bin, 'sleep'), - `#!/usr/bin/env bash\ncase "$1" in 0.*) exec ${realSleep} "$1" ;; esac\nexit 0\n`, - ) - for (const name of ['adb', 'node', 'curl', 'sleep']) { + for (const name of ['adb', 'node']) { chmodSync(join(bin, name), 0o755) } - const script = spawn(scriptFor(path, directory), { + const script = spawn(process.execPath, [scriptFor(path, directory)], { cwd: directory, detached: true, stdio: 'ignore', @@ -771,19 +964,37 @@ exit 0 const exited = new Promise((resolve) => script.once('exit', (code) => resolve(code)), ) - if (path === 'terminated') { + if (path === 'terminated' || path === 'killed') { await vi.waitFor( () => { expect(existsSync(waiting)).toEqual(true) }, {timeout: 10_000}, ) - process.kill(script.pid ?? 0, 'SIGTERM') + process.kill(script.pid ?? 0, path === 'killed' ? 'SIGKILL' : 'SIGTERM') } const status = await exited + if (path !== 'killed') { + readersAtReturn = JSON.parse( + readFileSync(join(directory, 'at-return.json'), 'utf8'), + ) + } const started = existsSync(readers) ? readFileSync(readers, 'utf8').trim().split('\n').map(Number) : [] + if (path === 'killed') { + const captures = readFileSync(join(directory, 'capture-pids'), 'utf8') + .trim() + .split('\n') + .map(Number) + await vi.waitFor( + () => + expect( + [...started, ...captures].filter((pid) => isAlive(pid)), + ).toEqual([]), + {timeout: 8000}, + ) + } const left = started.filter((pid) => isAlive(pid)) for (const pid of left) { process.kill(pid, 'SIGKILL') @@ -793,34 +1004,51 @@ exit 0 } catch {} return {status, readersStarted: started.length > 0, readersLeft: left} } finally { + for (const name of ['readers', 'capture-pids']) { + const path = join(directory, name) + if (existsSync(path)) { + for (const pid of readFileSync(path, 'utf8') + .trim() + .split('\n') + .map(Number)) { + try { + process.kill(pid, 'SIGKILL') + } catch {} + } + } + } rmSync(directory, {recursive: true, force: true}) } } -/** - * The prep script `path` runs: `prepare.sh` itself, or for `runs past` a copy beside a copy of - * `diagnose.sh` with its Japanese deadline cut to 3 seconds. - */ function scriptFor(path: string, directory: string): string { - if (path !== 'runs past') { - return prepareScript + const source = `import {existsSync, readFileSync, writeFileSync} from 'node:fs' +import {prepare} from ${JSON.stringify(prepareScript)} +process.exitCode = await prepare({ + keyboard: process.env.THUMBJAR_KEYBOARD, + out: process.env.THUMBJAR_OUT_DIR, + pageUrl: process.env.THUMBJAR_PAGE_URL, + port: process.env.THUMBJAR_PAGE_PORT, + booted: Number(process.env.THUMBJAR_BOOTED), +}, { + node: ${JSON.stringify(join(directory, 'bin/node'))}, + setup: 'setup-ja.js', dictionary: 'dictionary.js', + sleep: async () => {}, request: async () => true, + deadline: undefined, +}) +const readers=${JSON.stringify(join(directory, 'readers'))} +if(existsSync(readers))writeFileSync(${JSON.stringify(join(directory, 'at-return.json'))},JSON.stringify(readFileSync(readers,'utf8').trim().split('\\n').map(Number).filter(pid=>{try{return !/^\\d+ \\(.*\\) Z /.test(readFileSync('/proc/'+pid+'/stat','utf8'))}catch{return false}}))) +` + const shortened = + path === 'runs past' + ? source.replace('deadline: undefined', 'deadline: 3') + : source + if (path === 'runs past') { + expect(shortened).not.toEqual(source) } - const copies = join(directory, 'prepare') - mkdirSync(copies) - const source = readFileSync(prepareScript, 'utf8') - const shortened = source.replace( - ' ja) deadline=862 ;;', - ' ja) deadline=3 ;;', - ) - expect(shortened).not.toEqual(source) - writeFileSync(join(copies, 'prepare.sh'), shortened) - copyFileSync( - fileURLToPath(new URL('diagnose.sh', import.meta.url)), - join(copies, 'diagnose.sh'), - ) - chmodSync(join(copies, 'prepare.sh'), 0o755) - chmodSync(join(copies, 'diagnose.sh'), 0o755) - return join(copies, 'prepare.sh') + const bootstrap = join(directory, 'prepare.mjs') + writeFileSync(bootstrap, shortened) + return bootstrap } /** Whether `pid` still runs. A process that died and waits to be reaped, a zombie, does not. */ @@ -858,3 +1086,244 @@ function playStoreAnswer(answer: 'disabled' | 'refused' | 'hung') { } return `exec ${realSleep} 60` } + +function deadlinePrep(stubborn: boolean) { + const directory = mkdtempSync(join(tmpdir(), 'thumbjar-deadline-')) + try { + const bin = join(directory, 'bin') + mkdirSync(bin) + writeFileSync( + join(bin, 'adb'), + `#!${process.execPath} +${stubborn ? "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000)" : ''} +`, + ) + chmodSync(join(bin, 'adb'), 0o755) + const bootstrap = join(directory, 'prep.mjs') + writeFileSync( + bootstrap, + `import {prepare,prepDeadlines} from ${JSON.stringify(prepareScript)} +${stubborn ? 'prepDeadlines.adb.killAfter=20' : ''} +process.exitCode = await prepare({keyboard:'en', out:${JSON.stringify(join(directory, 'out'))}, pageUrl:'http://localhost:1/', port:'', booted:0}, {deadline:1}) +`, + ) + const result = spawnSync(process.execPath, [bootstrap], { + encoding: 'utf8', + env: {...process.env, PATH: `${bin}:${process.env.PATH}`}, + }) + const phases = join(directory, 'out/phases.log') + return { + status: result.status, + failure: existsSync(phases) + ? readFileSync(phases, 'utf8') + .trimEnd() + .split('\n') + .at(-1) + ?.replace(/^PHASE \d\d:\d\d:\d\d /, '') + : undefined, + } + } finally { + rmSync(directory, {recursive: true, force: true}) + } +} + +async function controlledPrepare( + settings: PrepareOptions, + http = false, + previousDirectory?: string, +) { + const directory = + previousDirectory ?? mkdtempSync(join(tmpdir(), 'thumbjar-controlled-')) + const out = join(directory, 'out') + const phaseFile = join(out, 'phases.log') + const earlierPhases = existsSync(phaseFile) + ? readFileSync(phaseFile, 'utf8').length + : 0 + if (settings.earlierDictionaryLog !== undefined) { + mkdirSync(out, {recursive: true}) + writeFileSync(join(out, 'ja-dictionary.log'), settings.earlierDictionaryLog) + } + const calls: string[] = [] + let reachedHTTP = false + const clock = new AbortController() + try { + for (const [name, value] of Object.entries(settings.env ?? {})) { + vi.stubEnv(name, value) + } + vi.spyOn(childProcesses, 'spawn').mockReturnValue(new ChildProcess()) + vi.spyOn(processes, 'execute').mockImplementation( + (command, args, options) => { + options.signal?.throwIfAborted() + calls.push( + `${command} ${args.map((argument) => (argument === out ? 'out' : argument)).join(' ')}`, + ) + let stdout = '' + let status = 0 + if ( + args.join(' ') === + 'shell pm disable-user --user 0 com.android.vending' + ) { + if (settings.playStore === 'disabled') { + stdout = 'Package com.android.vending new state: disabled-user\n' + } else { + status = 255 + stdout = + "Exception occurred while executing 'disable-user':\njava.lang.SecurityException: Shell cannot change component state for com.android.vending/null to 3\n" + } + } + if ( + args.join(' ') === 'shell pm list packages -d com.android.vending' && + settings.playStore === 'disabled' + ) { + stdout = 'package:com.android.vending\n' + } + let stderr = '' + const answer = { + 'shell cat /proc/sys/kernel/random/boot_id': + '62cd1ade-9b02-4723-ba9e-2749dda3d074\n', + 'shell echo $(date +%s) $(cat /proc/uptime)': + '1793515008 40.00 31.20\n', + 'logcat -d -v epoch -b main': + '--------- beginning of main\n 1793514994.898 668 799 I ArtService: Dexopt result\n', + 'logcat -S -b main': + 'size/num main\nTotal 1560142/9812\nNow 1102028/6947\n', + } + stdout = new Map(Object.entries(answer)).get(args.join(' ')) ?? stdout + if ( + args.join(' ') === 'reverse --list' && + settings.reverseList === 'exits 3' + ) { + status = 3 + } + if (command === 'node') { + if (args[0] === 'setup-ja.js' && settings.setup === 'exits 3') { + status = 3 + } + if (args[0] === 'dictionary.js' && args[1] === 'wait') { + if (settings.dictionary === 'never') { + status = 3 + stderr = `DICTIONARY FAILED: ${NEVER_LOADED}\nDictionary lines seen:\n1793515060.365 1756 4711 E native : Fallback to minimal engine.\n` + } else if (settings.dictionary === 'capture fault') { + status = 1 + stderr = `DICTIONARY FAILED: ${CAPTURE_FAULT}\n` + } else if (settings.dictionary === 'exits 3 without reason') { + status = 3 + } else { + stdout = 'DICTIONARY loaded 120.1s after boot\n' + } + } + if ( + args[0] === 'dictionary.js' && + args[1] === 'check' && + settings.dictionary === 'check fails' + ) { + status = 1 + stderr = + 'DICTIONARY FAILED: Gboard did not convert with its Japanese dictionary after the switch\n' + } + } + options.stdout?.(Buffer.from(stdout)) + options.stderr?.(Buffer.from(stderr)) + return Promise.resolve({status, stdout, stderr}) + }, + ) + if (http) { + let first = true + vi.spyOn(AbortSignal, 'timeout').mockImplementation(() => { + if (first) { + first = false + return clock.signal + } + return new AbortController().signal + }) + const real = await vi.importActual( + 'node:timers/promises', + ) + vi.mocked(delay).mockImplementation( + ( + milliseconds = 1, + value?: Value, + options?: Parameters[2], + ) => { + reachedHTTP = true + clock.abort() + return real.setTimeout(milliseconds, value, options) + }, + ) + } + const status = await prepareModule.prepare( + { + keyboard: settings.keyboard, + out, + pageUrl: http + ? 'http://localhost:1/' + : 'http://localhost:8765/editor.html', + port: http ? '' : '8765', + booted: 1793515010, + }, + { + node: 'node', + setup: 'setup-ja.js', + dictionary: 'dictionary.js', + sleep: async () => {}, + ...(http ? {deadline: 5} : {request: () => Promise.resolve(true)}), + }, + ) + const phases = readFileSync(phaseFile, 'utf8') + .slice(earlierPhases) + .trimEnd() + .split('\n') + .map((line) => + line + .replace(/^PHASE \d\d:\d\d:\d\d /, '') + .replace(/^settle done after \d+s/, 'settle done after 0s'), + ) + return { + status, + calls, + phases, + reachedHTTP, + ...(settings.keyboard === 'ja' + ? { + captureHeader: readFileSync( + join(out, 'boot-logcat.txt'), + 'utf8', + ).split('\n')[0], + } + : {}), + } + } finally { + vi.restoreAllMocks() + vi.unstubAllEnvs() + vi.mocked(delay).mockReset() + if (previousDirectory === undefined) { + rmSync(directory, {recursive: true, force: true}) + } + } +} + +function prepareWithoutProcesses(playStore: 'disabled' | 'refused') { + return prepare({keyboard: 'en', playStore}) +} + +async function deadlineAtHttpPause() { + const {status, phases, reachedHTTP} = await controlledPrepare( + {keyboard: 'en', playStore: 'disabled'}, + true, + ) + return {status, failure: phases.at(-1), reachedHTTP} +} + +async function prepare(options: PrepareOptions, directory?: string) { + const {status, calls, phases, captureHeader} = await controlledPrepare( + options, + false, + directory, + ) + return { + status, + calls, + phases, + ...(options.keyboard === 'ja' ? {captureHeader} : {}), + } +} diff --git a/packages/thumbjar/src/prepare/prepare.ts b/packages/thumbjar/src/prepare/prepare.ts new file mode 100644 index 0000000000..2a218a8fbc --- /dev/null +++ b/packages/thumbjar/src/prepare/prepare.ts @@ -0,0 +1,581 @@ +import {spawn, type ChildProcess} from 'node:child_process' +import { + appendFileSync, + closeSync, + mkdirSync, + openSync, + readFileSync, + writeFileSync, +} from 'node:fs' +import {join} from 'node:path' +import {setTimeout as delay} from 'node:timers/promises' +import { + CAPTURE_SEED_SECONDS, + KILL_GRACE_SECONDS, + PREP_KILL_SECONDS, + prepDeadlineSeconds, + SELF_UPDATE_BLOCK_SECONDS, + DICTIONARY_LOAD_SECONDS, + DICTIONARY_WAIT_KILL_SECONDS, + DICTIONARY_WAIT_TAIL_SECONDS, + DICTIONARY_CHECK_KILL_SECONDS, + DICTIONARY_CHECK_SECONDS, + PREP_ADB_SECONDS, + COMMAND_KILL_SECONDS, + PLAY_STORE_COMMAND_SECONDS, + PLAY_STORE_COMMAND_KILL_SECONDS, + SETTLE_SECONDS, + SETTLE_EVENT_SECONDS, + SETTLE_CLEAR_SECONDS, + SETTLE_POLL_SECONDS, + SETTLE_QUIET_SECONDS, + JAPANESE_SETUP_SECONDS, + JAPANESE_SETUP_KILL_SECONDS, + DIAGNOSTICS_SECONDS, + CAPTURE_KILL_WAIT_SECONDS, +} from '../device/budget.ts' +import { + Deadline, + entry, + execute as executeChild, + deviceEnvironment, + killGroup, + termination, + within, +} from '../process.ts' +import {diagnose} from './diagnose.ts' +import {httpReady} from './http.ts' + +export const prepDeadlines = { + prep: { + ja: prepDeadlineSeconds('ja'), + en: prepDeadlineSeconds('en'), + killAfter: PREP_KILL_SECONDS, + }, + bootCapture: {seconds: CAPTURE_SEED_SECONDS, killAfter: KILL_GRACE_SECONDS}, + selfUpdateBlock: { + seconds: SELF_UPDATE_BLOCK_SECONDS, + killAfter: KILL_GRACE_SECONDS, + }, + dictionaryLoad: DICTIONARY_LOAD_SECONDS, + dictionaryWait: { + seconds: DICTIONARY_WAIT_TAIL_SECONDS, + killAfter: DICTIONARY_WAIT_KILL_SECONDS, + }, + dictionaryCheck: { + seconds: DICTIONARY_CHECK_SECONDS, + killAfter: DICTIONARY_CHECK_KILL_SECONDS, + }, + adb: {seconds: PREP_ADB_SECONDS, killAfter: COMMAND_KILL_SECONDS}, + playStoreCommand: { + seconds: PLAY_STORE_COMMAND_SECONDS, + killAfter: PLAY_STORE_COMMAND_KILL_SECONDS, + }, + settle: { + seconds: SETTLE_SECONDS, + quiet: SETTLE_QUIET_SECONDS, + poll: SETTLE_POLL_SECONDS, + }, + settleEvents: { + seconds: SETTLE_EVENT_SECONDS, + killAfter: COMMAND_KILL_SECONDS, + }, + settleClear: {seconds: SETTLE_CLEAR_SECONDS, killAfter: COMMAND_KILL_SECONDS}, + diagnose: {seconds: DIAGNOSTICS_SECONDS, killAfter: COMMAND_KILL_SECONDS}, + setup: { + seconds: JAPANESE_SETUP_SECONDS, + killAfter: JAPANESE_SETUP_KILL_SECONDS, + }, + captureStop: {term: KILL_GRACE_SECONDS, kill: CAPTURE_KILL_WAIT_SECONDS}, +} + +export async function prepare( + options: { + keyboard: string + out: string + pageUrl: string + port: string + booted: number + }, + dependencies: { + node?: string + setup?: string + dictionary?: string + sleep?: (seconds: number, signal: AbortSignal) => Promise + request?: (url: string, signal: AbortSignal) => Promise + deadline?: number + } = {}, +): Promise { + const {keyboard, out, pageUrl, port, booted} = options + const env = deviceEnvironment(options) + const execute: typeof executeChild = (command, args, options) => + executeChild(command, args, {...options, env}) + mkdirSync(out, {recursive: true}) + const stop = termination() + const sleep = + dependencies.sleep ?? + (async (seconds, signal) => { + await delay(seconds * 1000, undefined, {signal}) + }) + const log = (message: string) => { + const now = new Date() + const time = [now.getHours(), now.getMinutes(), now.getSeconds()] + .map((value) => String(value).padStart(2, '0')) + .join(':') + const line = `PHASE ${time} ${message}\n` + appendFileSync(join(out, 'phases.log'), line) + process.stdout.write(line) + } + const fail = (message: string): never => { + log(`PREP FAILED: ${message}`) + throw new PrepFailure(1) + } + const adb = async (args: string[], signal: AbortSignal, capture = false) => { + const result = await execute('adb', args, { + ...prepDeadlines.adb, + signal, + ...(capture + ? {} + : {stdout: (chunk: Buffer) => process.stdout.write(chunk)}), + stderr: (chunk) => process.stderr.write(chunk), + }) + if (result.status === 124) { + log(`TIMEOUT adb ${args.join(' ')}`) + } + return result + } + const node = ( + file: string, + args: string[], + seconds: number, + killAfter: number, + name: string, + signal: AbortSignal, + ) => { + appendFileSync(join(out, name), '') + const output = (chunk: Buffer) => { + appendFileSync(join(out, name), chunk) + process.stdout.write(chunk) + } + return execute(dependencies.node ?? process.execPath, [file, ...args], { + seconds, + killAfter, + signal, + stdout: output, + stderr: output, + }) + } + let reader: ChildProcess | undefined + let stopping: Promise | undefined + const stopCapture = () => + (stopping ??= (async () => { + if (reader?.pid === undefined) { + return + } + killGroup(reader.pid, 'SIGTERM') + await waitForExit(reader, prepDeadlines.captureStop.term * 1000) + killGroup(reader.pid, 'SIGKILL') + await waitForExit(reader, prepDeadlines.captureStop.kill * 1000) + })()) + const captureAbort = () => { + void stopCapture() + } + stop.signal.addEventListener('abort', captureAbort, {once: true}) + try { + if (keyboard === 'ja') { + log('boot capture') + try { + await within( + prepDeadlines.bootCapture.seconds, + prepDeadlines.bootCapture.killAfter, + stop.signal, + async (signal) => { + const bootId = ( + await adb( + ['shell', 'cat', '/proc/sys/kernel/random/boot_id'], + signal, + true, + ) + ).stdout + .replaceAll('\r', '') + .trimEnd() + const values = ( + await adb( + ['shell', 'echo $(date +%s) $(cat /proc/uptime)'], + signal, + true, + ) + ).stdout + .trim() + .split(/\s+/) + .map(Number) + const btime = + values.length >= 2 + ? Math.trunc(values[0]! - values[1]!) + : undefined + if (!bootId || btime === undefined || !Number.isFinite(btime)) { + fail('boot capture') + } + const file = join(out, 'boot-logcat.txt') + writeFileSync(file, `CAPTURE boot_id=${bootId} btime=${btime}\n`) + if ( + (await adb(['logcat', '-G', '16M', '-b', 'main'], signal)) + .status !== 0 + ) { + log('logcat -G failed') + } + appendFileSync(file, 'CAPTURE_SEGMENT seed\n') + const output = (chunk: Buffer) => appendFileSync(file, chunk) + const seed = await execute( + 'adb', + ['logcat', '-d', '-v', 'epoch', '-b', 'main'], + {...prepDeadlines.adb, signal, stdout: output, stderr: output}, + ) + if (seed.status !== 0) { + fail('boot capture') + } + const stats = ( + await adb(['logcat', '-S', '-b', 'main'], signal, true) + ).stdout + const total = /^Total\s+(\S+)/m.exec(stats)?.[1] + const now = /^Now\s+(\S+)/m.exec(stats)?.[1] + if (total && now) { + appendFileSync( + file, + `CAPTURE_STATS main total=${total} now=${now}\n`, + ) + } + }, + ) + } catch (error) { + if (stop.signal.aborted) { + throw error + } + fail('boot capture ran past 15s or failed') + } + const descriptor = openSync(join(out, 'boot-logcat.txt'), 'a') + try { + reader = spawn(process.execPath, [entry('prepare/capture')], { + detached: true, + env, + stdio: ['ignore', descriptor, descriptor, 'ipc'], + }) + } finally { + closeSync(descriptor) + } + } + const deadline = + dependencies.deadline ?? + prepDeadlines.prep[keyboard === 'ja' ? 'ja' : 'en'] + await within( + deadline, + prepDeadlines.prep.killAfter, + stop.signal, + async (signal) => { + signal.addEventListener('abort', captureAbort, {once: true}) + if ( + ( + await adb( + ['shell', 'settings', 'put', 'global', 'hide_error_dialogs', '1'], + signal, + ) + ).status !== 0 + ) { + fail('hide error dialogs') + } + try { + await within( + prepDeadlines.selfUpdateBlock.seconds, + prepDeadlines.selfUpdateBlock.killAfter, + signal, + async (blockSignal) => { + const disabled = await execute( + 'adb', + [ + 'shell', + 'pm', + 'disable-user', + '--user', + '0', + 'com.android.vending', + ], + { + ...prepDeadlines.playStoreCommand, + signal: blockSignal, + merge: true, + }, + ) + const output = disabled.stdout + .replace(/\n+$/, '') + .replaceAll('\r', '') + .replaceAll('\n', ' ') + .replace(/ $/, '') + const disable = + disabled.status === 0 + ? 'succeeded' + : [124, 137].includes(disabled.status) + ? 'failed (no answer within 8s)' + : `failed (exit ${disabled.status}: ${output})` + const listed = await execute( + 'adb', + [ + 'shell', + 'pm', + 'list', + 'packages', + '-d', + 'com.android.vending', + ], + {...prepDeadlines.playStoreCommand, signal: blockSignal}, + ) + log( + `self-update block: disabling com.android.vending ${disable}, pm list packages -d ${listed.stdout.replaceAll('\r', '').split('\n').includes('package:com.android.vending') ? 'lists' : 'does not list'} it`, + ) + }, + ) + } catch (error) { + signal.throwIfAborted() + if (!(error instanceof Deadline)) { + throw error + } + log('self-update block: ran past 20s') + } + log('settle start') + let quiet = 0 + const start = Date.now() + while (Date.now() - start < prepDeadlines.settle.seconds * 1000) { + const events = await execute( + 'adb', + ['logcat', '-d', '-b', 'events'], + {...prepDeadlines.settleEvents, signal}, + ) + if (events.stdout.includes('am_anr')) { + quiet = 0 + await execute('adb', ['logcat', '-b', 'events', '-c'], { + ...prepDeadlines.settleClear, + signal, + stdout: (chunk) => process.stdout.write(chunk), + stderr: (chunk) => process.stderr.write(chunk), + }) + } else { + quiet += prepDeadlines.settle.poll + } + if (quiet >= prepDeadlines.settle.quiet) { + break + } + await sleep(prepDeadlines.settle.poll, signal) + } + log( + `settle done after ${Math.floor((Date.now() - start) / 1000)}s quiet=${quiet}`, + ) + log('diagnose start') + try { + await within( + prepDeadlines.diagnose.seconds, + prepDeadlines.diagnose.killAfter, + signal, + (diagnoseSignal) => diagnose(out, diagnoseSignal, env), + ) + } catch (error) { + signal.throwIfAborted() + if (!(error instanceof Deadline)) { + throw error + } + log(`diagnose rc=${error.status}`) + } + log('chrome flags') + if ( + ( + await adb( + [ + 'shell', + 'am', + 'set-debug-app', + '--persistent', + 'com.android.chrome', + ], + signal, + ) + ).status !== 0 + ) { + fail('chrome debug app') + } + if ( + ( + await adb( + [ + 'shell', + 'echo "_ --disable-fre --no-default-browser-check --no-first-run --disable-features=SigninPromo" > /data/local/tmp/chrome-command-line', + ], + signal, + ) + ).status !== 0 + ) { + fail('chrome command line') + } + await adb( + ['shell', 'cat', '/data/local/tmp/chrome-command-line'], + signal, + ) + log('soft keyboard') + if ( + ( + await adb( + [ + 'shell', + 'settings', + 'put', + 'secure', + 'show_ime_with_hard_keyboard', + '1', + ], + signal, + ) + ).status !== 0 + ) { + fail('soft keyboard') + } + await adb( + ['shell', 'settings', 'get', 'secure', 'show_ime_with_hard_keyboard'], + signal, + ) + if (keyboard === 'ja') { + log('japanese setup') + const setup = await node( + dependencies.setup ?? entry('prepare/setup-ja'), + [out], + prepDeadlines.setup.seconds, + prepDeadlines.setup.killAfter, + 'ja-setup.log', + signal, + ) + log(`japanese setup rc=${setup.status}`) + if (setup.status !== 0) { + fail('japanese setup, see ja-setup.log') + } + const waitSeconds = Math.max( + 0, + booted + + prepDeadlines.dictionaryLoad - + Math.floor(Date.now() / 1000), + ) + log('japanese dictionary wait') + const earlier = read(join(out, 'ja-dictionary.log')).length + const wait = await node( + dependencies.dictionary ?? entry('prepare/dictionary'), + ['wait', out, String(booted)], + waitSeconds + prepDeadlines.dictionaryWait.seconds, + prepDeadlines.dictionaryWait.killAfter, + 'ja-dictionary.log', + signal, + ) + log(`japanese dictionary wait rc=${wait.status}`) + if (wait.status !== 0) { + const reason = read(join(out, 'ja-dictionary.log')) + .subarray(earlier) + .toString() + .split('\n') + .find((line) => line.startsWith('DICTIONARY FAILED: ')) + if (!reason) { + return fail( + 'japanese dictionary wait gave no reason, see ja-dictionary.log', + ) + } + log( + `PREP FAILED: ${reason.slice('DICTIONARY FAILED: '.length)} See ja-dictionary.log for the dictionary lines it saw.`, + ) + throw new PrepFailure(wait.status === 3 ? 3 : 1) + } + log('japanese dictionary check') + const check = await node( + dependencies.dictionary ?? entry('prepare/dictionary'), + ['check', out, pageUrl], + prepDeadlines.dictionaryCheck.seconds, + prepDeadlines.dictionaryCheck.killAfter, + 'ja-dictionary.log', + signal, + ) + log(`japanese dictionary check rc=${check.status}`) + if (check.status !== 0) { + fail('japanese dictionary check, see ja-dictionary.log') + } + await stopCapture() + } + log('logcat') + await adb(['logcat', '-c'], signal) + if (!(await (dependencies.request ?? httpReady)(pageUrl, signal))) { + fail('http server, see http.log') + } + log('http ok') + if (port) { + if ( + (await adb(['reverse', `tcp:${port}`, `tcp:${port}`], signal)) + .status !== 0 + ) { + fail('adb reverse') + } + const listed = await adb(['reverse', '--list'], signal) + if (listed.status !== 0) { + log(`adb reverse --list rc=${listed.status}`) + } + } + }, + ) + return 0 + } catch (error) { + if (stop.signal.aborted) { + return stop.status() + } + if (error instanceof PrepFailure) { + return error.status + } + if (error instanceof Deadline) { + if (error.status === 124) { + log(`PREP FAILED: prep ran past ${error.seconds}s`) + } + return 1 + } + throw error + } finally { + await stopCapture() + stop.dispose() + } +} + +function read(file: string): Buffer { + try { + return readFileSync(file) + } catch { + return Buffer.alloc(0) + } +} + +class PrepFailure extends Error { + readonly status: number + constructor(status: number) { + super(`prep status ${status}`) + this.status = status + } +} + +async function waitForExit(child: ChildProcess, milliseconds: number) { + if (child.exitCode !== null || child.signalCode !== null) { + return + } + const timer = new AbortController() + let closed: (() => void) | undefined + try { + await Promise.race([ + new Promise((resolve) => { + closed = () => resolve() + child.once('close', closed) + }), + delay(milliseconds, undefined, {signal: timer.signal}), + ]) + } finally { + timer.abort() + if (closed) { + child.off('close', closed) + } + } +} diff --git a/packages/thumbjar/src/prepare/setup-ja.ts b/packages/thumbjar/src/prepare/setup-ja.ts index 2a355d6adb..5963e1d487 100644 --- a/packages/thumbjar/src/prepare/setup-ja.ts +++ b/packages/thumbjar/src/prepare/setup-ja.ts @@ -8,6 +8,7 @@ import {writeFileSync} from 'node:fs' import {adbOutput, adbPassthrough} from '../device/adb.ts' import {Device, type UiObject} from '../device/u2.ts' import {pySorted, sleep} from '../device/util.ts' +import {openJapaneseLayouts, TWELVE} from './open-japanese-layouts.ts' const GBOARD = 'com.google.android.inputmethod.latin' const out = process.argv[2]! @@ -15,7 +16,6 @@ let step = 0 // Gboard preselects 12 keys (kana flick) and opens on it, so keep QWERTY (romaji) as the only layout. // Gboard v12 labels the tiles in English, v15 in Japanese (12 キー, 手書き). -const TWELVE = '^12 (keys|キー)$' const QWERTY = '^QWERTY$' const HANDWRITING = '^(Handwriting|手書き)$' @@ -87,8 +87,7 @@ async function main(): Promise { await snap(device, 'missing-japanese-row') throw new Error('no Japanese row in the search results') } - await row.click() - await tile(device, TWELVE).wait(15) + await openJapaneseLayouts(device, (name) => snap(device, name)) await snap(device, 'japanese') for (const [pattern, want] of [ diff --git a/packages/thumbjar/src/process-child.ts b/packages/thumbjar/src/process-child.ts new file mode 100644 index 0000000000..cfe28ae437 --- /dev/null +++ b/packages/thumbjar/src/process-child.ts @@ -0,0 +1,194 @@ +import {spawn, type ChildProcess} from 'node:child_process' +import {constants} from 'node:os' + +type Output = (chunk: Buffer) => void + +export type ChildResult = { + code: number | null + status: number + stdout: string + stderr: string + reason: unknown + error: Error | undefined + unstopped: boolean + pid: number | undefined +} + +export type ChildOptions = { + seconds?: number + killAfter: number + killWait: number + signal?: AbortSignal + env?: NodeJS.ProcessEnv + ipc?: boolean + detached?: boolean + groupPid?: number + output?: 'pipe' | 'inherit' | number + collect?: boolean + stdout?: Output + stderr?: Output + timeoutReason?: () => unknown + onSpawn?: (child: ChildProcess) => void + reject?: (result: ChildResult) => unknown + release?: boolean +} + +export function runChildProcess( + command: string, + args: string[], + options: ChildOptions, +): Promise { + options.signal?.throwIfAborted() + return new Promise((resolve, reject) => { + const output = options.output ?? 'pipe' + const child = spawn(command, args, { + detached: options.detached ?? false, + ...(options.env === undefined ? {} : {env: options.env}), + stdio: options.ipc + ? ['ignore', output, output, 'ipc'] + : ['ignore', output, output], + }) + const stdout: Buffer[] = [] + const stderr: Buffer[] = [] + child.stdout?.on('data', (chunk: Buffer) => { + if (options.collect !== false) { + stdout.push(chunk) + } + options.stdout?.(chunk) + }) + child.stderr?.on('data', (chunk: Buffer) => { + if (options.collect !== false) { + stderr.push(chunk) + } + options.stderr?.(chunk) + }) + let reason: unknown + let stopped = false + let killed = false + let finished = false + let killAt = Infinity + let killTimer: NodeJS.Timeout | undefined + const timers: NodeJS.Timeout[] = [] + const finish = ( + code: number | null, + signal: NodeJS.Signals | null, + error?: Error, + unstopped = false, + ) => { + if (finished) { + return + } + finished = true + for (const timer of timers) { + clearTimeout(timer) + } + options.signal?.removeEventListener('abort', abort) + if (options.release && unstopped) { + child.stdout?.destroy() + child.stderr?.destroy() + child.unref() + } + const result = { + code, + status: killed + ? 137 + : stopped + ? 124 + : error + ? 127 + : (code ?? + (signal === null ? 143 : 128 + constants.signals[signal])), + stdout: Buffer.concat(stdout).toString('utf8'), + stderr: Buffer.concat(stderr).toString('utf8'), + reason, + error, + unstopped, + pid: child.pid, + } + try { + const failure = options.reject?.(result) + if (failure !== undefined) { + reject(failure) + } else { + resolve(result) + } + } catch (error) { + reject(error) + } + } + const send = (signal: NodeJS.Signals) => { + if (options.groupPid !== undefined) { + killGroup(options.groupPid, signal) + } else { + child.kill(signal) + } + } + const stop = (cause: unknown) => { + if (!stopped) { + stopped = true + reason = cause + send('SIGTERM') + } + const nextKillAt = Date.now() + options.killAfter * 1000 + if (nextKillAt >= killAt) { + return + } + killAt = nextKillAt + if (killTimer) { + clearTimeout(killTimer) + } + killTimer = setTimeout( + () => { + killed = true + send('SIGKILL') + timers.push( + setTimeout( + () => finish(null, 'SIGKILL', undefined, true), + options.killWait * 1000, + ), + ) + }, + Math.max(0, killAt - Date.now()), + ) + timers.push(killTimer) + } + const abort = () => stop(options.signal!.reason) + if (options.seconds !== undefined) { + timers.push( + setTimeout( + () => stop(options.timeoutReason?.()), + options.seconds * 1000, + ), + ) + } + options.signal?.addEventListener('abort', abort, {once: true}) + child.on('error', (error) => finish(null, null, error)) + child.on('close', (code, signal) => finish(code, signal)) + child.on('exit', (code, signal) => { + if (options.release && signal === 'SIGKILL') { + timers.push( + setTimeout( + () => finish(code, signal, undefined, true), + options.killWait * 1000, + ), + ) + } + }) + options.onSpawn?.(child) + }) +} + +export function killGroup(pid: number | undefined, signal: NodeJS.Signals) { + if (pid === undefined) { + return + } + try { + process.kill(-pid, signal) + } catch (error) { + if ( + !(error instanceof Error && 'code' in error && error.code === 'ESRCH') + ) { + throw error + } + } +} diff --git a/packages/thumbjar/src/process.test-support.ts b/packages/thumbjar/src/process.test-support.ts new file mode 100644 index 0000000000..54a08c9fed --- /dev/null +++ b/packages/thumbjar/src/process.test-support.ts @@ -0,0 +1,9 @@ +import {readFileSync} from 'node:fs' + +export function processAlive(pid: number) { + try { + return !/^\d+ \(.*\) Z /.test(readFileSync(`/proc/${pid}/stat`, 'utf8')) + } catch { + return false + } +} diff --git a/packages/thumbjar/src/process.test.ts b/packages/thumbjar/src/process.test.ts new file mode 100644 index 0000000000..d49ae58e20 --- /dev/null +++ b/packages/thumbjar/src/process.test.ts @@ -0,0 +1,310 @@ +import {spawn, spawnSync} from 'node:child_process' +import { + existsSync, + readdirSync, + readFileSync, + mkdtempSync, + writeFileSync, + watch, + rmSync, +} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' +import {fileURLToPath, pathToFileURL} from 'node:url' +import {expect, onTestFinished, test, vi} from 'vitest' +import {processAlive} from './process.test-support.ts' +import {entry, execute} from './process.ts' + +const workers = [ + 'bin', + 'guard', + 'prepare/capture', + 'prepare/setup-ja', + 'prepare/dictionary', + 'run/cap', + 'run/check', + 'run/logcat', +] + +test('resolves every default worker from the source package root', () => { + expect( + workers.map((name) => ({ + name, + exists: existsSync(entry(name)), + path: entry(name).endsWith(`/src/${name}.ts`), + })), + ).toEqual([ + {name: 'bin', exists: true, path: true}, + {name: 'guard', exists: true, path: true}, + {name: 'prepare/capture', exists: true, path: true}, + {name: 'prepare/setup-ja', exists: true, path: true}, + {name: 'prepare/dictionary', exists: true, path: true}, + {name: 'run/cap', exists: true, path: true}, + {name: 'run/check', exists: true, path: true}, + {name: 'run/logcat', exists: true, path: true}, + ]) +}) + +test('resolves every default worker from the built package root', async () => { + const directory = fileURLToPath( + new URL('dist/', import.meta.resolve('thumbjar/package.json')), + ) + const processChunk = readdirSync(directory).find( + (name) => + name.startsWith('process-') && + name.endsWith('.js') && + readFileSync(directory + name, 'utf8').includes('function entry('), + ) + if (!processChunk) { + throw new Error('build thumbjar before running its unit tests') + } + const compiled = await import(pathToFileURL(directory + processChunk).href) + const resolveEntry: typeof entry = Object.values(compiled).find( + (value): value is typeof entry => + typeof value === 'function' && value.name === 'entry', + )! + expect( + workers.map((name) => ({ + name, + exists: existsSync(resolveEntry(name)), + path: resolveEntry(name).endsWith(`/dist/${name}.js`), + })), + ).toEqual([ + {name: 'bin', exists: true, path: true}, + {name: 'guard', exists: true, path: true}, + {name: 'prepare/capture', exists: true, path: true}, + {name: 'prepare/setup-ja', exists: true, path: true}, + {name: 'prepare/dictionary', exists: true, path: true}, + {name: 'run/cap', exists: true, path: true}, + {name: 'run/check', exists: true, path: true}, + {name: 'run/logcat', exists: true, path: true}, + ]) +}) + +test('ends the stage before execute resolves when only its guard is SIGKILLed', async () => { + let stagePid: number | undefined + let guardPid: number | undefined + const running = execute('bash', ['-c', 'echo "$$ $PPID"; exec sleep 301.5'], { + seconds: 3, + killAfter: 2, + stdout: (chunk) => { + ;[stagePid, guardPid] = chunk.toString().trim().split(' ').map(Number) + }, + }) + try { + await vi.waitFor(() => expect(typeof guardPid).toEqual('number')) + process.kill(guardPid!, 'SIGKILL') + expect((await running).status).toEqual(137) + expect( + existsSync(`/proc/${stagePid}/stat`) && + !/^\d+ \(.*\) Z /.test(readFileSync(`/proc/${stagePid}/stat`, 'utf8')), + ).toEqual(false) + } finally { + for (const pid of [stagePid, guardPid]) { + if (pid !== undefined) { + try { + process.kill(pid, 'SIGKILL') + } catch {} + } + } + await running + } +}, 10_000) + +test('requires thumbjar to start a guard with IPC', () => { + const result = spawnSync(process.execPath, [entry('guard')], { + encoding: 'utf8', + }) + expect({status: result.status, stderr: result.stderr}).toEqual({ + status: 2, + stderr: 'thumbjar guard must be started by thumbjar\n', + }) +}) + +test('forwards process-only SIGTERM through the guard and leaves no stage writer', async () => { + expect(await stopGuardedStage('SIGTERM', false)).toEqual({ + status: 143, + ready: ['stage', 'descendant'], + alive: [], + terminated: ['descendant', 'stage'], + }) +}) + +test('forwards process-only SIGINT through the guard and leaves no stage writer', async () => { + expect(await stopGuardedStage('SIGINT', false)).toEqual({ + status: 130, + ready: ['stage', 'descendant'], + alive: [], + terminated: ['descendant', 'stage'], + }) +}) + +test('ends the stage and descendants when the run group receives SIGTERM', async () => { + expect(await stopGuardedStage('SIGTERM', true)).toEqual({ + status: 143, + ready: ['stage', 'descendant'], + alive: [], + terminated: ['descendant', 'stage'], + }) +}) + +test('ends the stage and descendants when the run group receives SIGKILL', async () => { + expect(await stopGuardedStage('SIGKILL', true)).toEqual({ + status: null, + ready: ['stage', 'descendant'], + alive: [], + terminated: ['descendant', 'stage'], + }) +}) + +test('leaves no recorded writer after the guard shuts down on disconnect', async () => { + expect(await stopGuardedStage('disconnect', false)).toEqual({ + status: null, + ready: ['stage', 'descendant'], + alive: [], + terminated: ['descendant', 'stage'], + }) +}) + +test('ends the stage and descendants when only the run process is SIGKILLed', async () => { + expect(await stopGuardedStage('SIGKILL', false)).toEqual({ + status: null, + ready: ['stage', 'descendant'], + alive: [], + terminated: ['descendant', 'stage'], + }) +}) + +async function stopGuardedStage( + signal: 'SIGTERM' | 'SIGINT' | 'SIGKILL' | 'disconnect', + group: boolean, +) { + const directory = mkdtempSync(join(tmpdir(), 'thumbjar-guard-contract-')) + const resources: { + watcher?: ReturnType + exited?: Promise + } = {} + onTestFinished(async () => { + resources.watcher?.close() + const records = join(directory, 'writers') + const runnerRecord = join(directory, 'runner-pid') + const lines = existsSync(records) + ? readFileSync(records, 'utf8') + .trim() + .split('\n') + .map((line) => line.split(' ')) + : [] + const runnerPid = existsSync(runnerRecord) + ? Number(readFileSync(runnerRecord, 'utf8')) + : undefined + const pids = [ + runnerPid, + ...lines.flatMap((line) => line.slice(1).map(Number)), + ].filter((pid): pid is number => pid !== undefined) + const groups = [ + runnerPid, + ...lines + .filter((line) => line[0] === 'stage') + .map((line) => Number(line[2])), + ].filter((pid): pid is number => pid !== undefined) + for (const pid of groups) { + try { + process.kill(-pid, 'SIGKILL') + } catch {} + } + for (const pid of pids) { + if (processAlive(pid)) { + try { + process.kill(pid, 'SIGKILL') + } catch {} + } + } + await resources.exited + await vi.waitFor(() => expect(pids.filter(processAlive)).toEqual([])) + rmSync(directory, {recursive: true, force: true}) + }) + const records = join(directory, 'writers') + const stage = join(directory, 'stage.sh') + const runner = join(directory, 'runner.mjs') + writeFileSync( + stage, + `#!/bin/bash +trap 'echo stage >> "${directory}/terminated"; wait; exit 0' TERM INT +printf 'stage %s %s\\n' "$$" "$PPID" >> '${records}' +bash -c 'trap "echo descendant >> ${directory}/terminated; exit 0" TERM INT; printf "descendant %s\\n" "$$" >> "${records}"; read -r line < "${directory}/hold"' & +wait +`, + ) + const fifo = spawnSync('mkfifo', [join(directory, 'hold')]) + expect(fifo.status).toEqual(0) + writeFileSync( + runner, + `import {execute, termination} from ${JSON.stringify(new URL('process.ts', import.meta.url).href)} +const stop=termination() +try {await execute('/bin/bash',[${JSON.stringify(stage)}],{killAfter:0.5,signal:stop.signal})}catch{}finally{stop.dispose()} +process.exitCode=stop.status() +`, + ) + const child = + signal === 'disconnect' + ? spawn( + process.execPath, + [ + entry('guard'), + JSON.stringify({ + command: '/bin/bash', + args: [stage], + killAfter: 0.5, + }), + ], + {detached: true, stdio: ['ignore', 'ignore', 'ignore', 'ipc']}, + ) + : spawn(process.execPath, [runner], {detached: true, stdio: 'ignore'}) + writeFileSync(join(directory, 'runner-pid'), String(child.pid)) + resources.exited = new Promise((resolve) => + child.once('exit', resolve), + ) + let pids: number[] = [] + const ready = Promise.withResolvers() + const recordWriters = () => { + if (!existsSync(records)) { + return + } + const lines = readFileSync(records, 'utf8') + .trim() + .split('\n') + .map((line) => line.split(' ')) + if (lines.length === 2) { + pids = lines.flatMap((line) => line.slice(1).map(Number)) + ready.resolve() + } + } + resources.watcher = watch(directory, recordWriters) + recordWriters() + await ready.promise + expect( + readFileSync(records, 'utf8') + .trim() + .split('\n') + .map((line) => line.split(' ')[0]), + ).toEqual(['stage', 'descendant']) + if (signal === 'disconnect') { + child.disconnect() + } else { + process.kill(group ? -child.pid! : child.pid!, signal) + } + const status = await resources.exited + await vi.waitFor(() => expect(pids.filter(processAlive)).toEqual([])) + return { + status, + ready: readFileSync(records, 'utf8') + .trim() + .split('\n') + .map((line) => line.split(' ')[0]), + alive: pids.filter(processAlive), + terminated: readFileSync(join(directory, 'terminated'), 'utf8') + .trim() + .split('\n') + .sort(), + } +} diff --git a/packages/thumbjar/src/process.ts b/packages/thumbjar/src/process.ts new file mode 100644 index 0000000000..c02983753d --- /dev/null +++ b/packages/thumbjar/src/process.ts @@ -0,0 +1,198 @@ +import {mkdtempSync, openSync, closeSync, readFileSync, rmSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' +import {fileURLToPath} from 'node:url' +import {KILL_GRACE_SECONDS} from './device/budget.ts' +import {killGroup, runChildProcess} from './process-child.ts' +export {killGroup} from './process-child.ts' + +type Output = (chunk: Buffer) => void + +export async function execute( + command: string, + args: string[], + options: { + env?: NodeJS.ProcessEnv + seconds?: number + group?: boolean + collect?: boolean + merge?: boolean + killAfter: number + signal?: AbortSignal + stdout?: Output + stderr?: Output + }, +): Promise<{status: number; stdout: string; stderr: string}> { + options.signal?.throwIfAborted() + const merged = options.merge + ? mkdtempSync(join(tmpdir(), 'thumbjar-output-')) + : undefined + const mergedFile = merged === undefined ? undefined : join(merged, 'output') + const descriptor = + mergedFile === undefined ? undefined : openSync(mergedFile, 'w') + let removeAbort = () => {} + let guardedStatus: number | undefined + try { + const guarded = options.group !== false + const result = await runChildProcess( + guarded ? process.execPath : command, + guarded + ? [ + entry('guard'), + JSON.stringify({ + command, + args, + seconds: options.seconds, + killAfter: options.killAfter, + }), + ] + : args, + { + ...(guarded + ? { + detached: true, + ipc: true, + onSpawn: (child) => { + child.once('exit', () => killGroup(child.pid, 'SIGKILL')) + child.on('message', (message: {status: number}) => { + guardedStatus = message.status + }) + const abort = () => { + if (child.connected) { + child.send( + { + killAfter: + options.signal?.reason instanceof Deadline + ? options.signal.reason.killAfter + : options.killAfter, + }, + () => {}, + ) + } + } + options.signal?.addEventListener('abort', abort, {once: true}) + removeAbort = () => + options.signal?.removeEventListener('abort', abort) + }, + } + : { + ...(options.seconds === undefined + ? {} + : {seconds: options.seconds}), + ...(options.signal === undefined ? {} : {signal: options.signal}), + }), + ...(options.env === undefined ? {} : {env: options.env}), + killAfter: options.killAfter, + killWait: KILL_GRACE_SECONDS, + output: descriptor ?? 'pipe', + ...(options.collect === undefined ? {} : {collect: options.collect}), + ...(options.stdout === undefined ? {} : {stdout: options.stdout}), + ...(options.stderr === undefined ? {} : {stderr: options.stderr}), + release: true, + }, + ) + let stdout = result.stdout + let stderr = result.stderr + if (result.error) { + const message = `${result.error.message}\n` + stderr += message + options.stderr?.(Buffer.from(message)) + } + if (mergedFile !== undefined) { + const output = readFileSync(mergedFile) + stdout = output.toString() + options.stdout?.(output) + } + options.signal?.throwIfAborted() + return {status: guardedStatus ?? result.status, stdout, stderr} + } finally { + removeAbort() + if (descriptor !== undefined) { + closeSync(descriptor) + } + if (merged !== undefined) { + rmSync(merged, {recursive: true, force: true}) + } + } +} + +export function termination() { + const controller = new AbortController() + let status = 0 + const handlers = (['SIGTERM', 'SIGINT', 'SIGHUP'] as const).map((signal) => { + const handler = () => { + status = signal === 'SIGINT' ? 130 : signal === 'SIGHUP' ? 129 : 143 + controller.abort(new Error(signal)) + } + process.on(signal, handler) + return {signal, handler} + }) + return { + signal: controller.signal, + status: () => status, + dispose: () => { + for (const {signal, handler} of handlers) { + process.off(signal, handler) + } + }, + } +} + +export class Deadline extends Error { + readonly status = 124 + readonly seconds: number + readonly killAfter: number + constructor(seconds: number, killAfter: number) { + super(`deadline after ${seconds}s`) + this.seconds = seconds + this.killAfter = killAfter + } +} + +export async function within( + seconds: number, + killAfter: number, + signal: AbortSignal, + task: (signal: AbortSignal) => Promise, +): Promise { + const controller = new AbortController() + const deadline = AbortSignal.timeout(seconds * 1000) + const abort = () => controller.abort(new Deadline(seconds, killAfter)) + deadline.addEventListener('abort', abort, {once: true}) + const combined = AbortSignal.any([signal, controller.signal]) + try { + return await task(combined) + } catch (error) { + combined.throwIfAborted() + throw error + } finally { + deadline.removeEventListener('abort', abort) + } +} + +export function entry(name: string) { + const source = import.meta.url.endsWith('.ts') + return fileURLToPath( + new URL( + `${source ? 'src' : 'dist'}/${name}.${source ? 'ts' : 'js'}`, + import.meta.resolve('thumbjar/package.json'), + ), + ) +} + +export function deviceEnvironment(options: { + keyboard: string + out: string + pageUrl: string + port: string + booted: number +}): NodeJS.ProcessEnv { + return { + ...process.env, + THUMBJAR_KEYBOARD: options.keyboard, + THUMBJAR_OUT_DIR: options.out, + THUMBJAR_PAGE_URL: options.pageUrl, + THUMBJAR_PAGE_PORT: options.port, + THUMBJAR_BOOTED: String(options.booted), + } +} diff --git a/packages/thumbjar/src/run/check.ts b/packages/thumbjar/src/run/check.ts index 91d50d9c7b..5008441055 100644 --- a/packages/thumbjar/src/run/check.ts +++ b/packages/thumbjar/src/run/check.ts @@ -1,10 +1,11 @@ import {spawnSync} from 'node:child_process' import {appendFileSync} from 'node:fs' import {deviceVerdict} from '../device/adb.ts' +import {DEVICE_STATE_SECONDS} from '../device/budget.ts' const adb = spawnSync('adb', ['get-state'], { encoding: 'utf8', - timeout: 5_000, + timeout: DEVICE_STATE_SECONDS * 1000, killSignal: 'SIGKILL', }) const verdict = deviceVerdict({ diff --git a/packages/thumbjar/src/run/logcat.ts b/packages/thumbjar/src/run/logcat.ts new file mode 100644 index 0000000000..bc3842eeec --- /dev/null +++ b/packages/thumbjar/src/run/logcat.ts @@ -0,0 +1,15 @@ +import {writeFileSync, appendFileSync} from 'node:fs' +import {COMMAND_KILL_SECONDS} from '../device/budget.ts' +import {execute} from '../process.ts' + +const file = process.argv[3]! +writeFileSync(file, '') +const output = (chunk: Buffer) => appendFileSync(file, chunk) +await execute('adb', ['logcat', '-v', 'time'], { + seconds: Number(process.argv[2]), + killAfter: COMMAND_KILL_SECONDS, + group: false, + collect: false, + stdout: output, + stderr: output, +}) diff --git a/packages/thumbjar/src/run/run.sh b/packages/thumbjar/src/run/run.sh deleted file mode 100644 index 5bed9d7018..0000000000 --- a/packages/thumbjar/src/run/run.sh +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env bash -set -u -file=$1 -lang=$2 -page_dir=$3 -config=$4 -project=$5 -port=$6 -out=$7 -mkdir -p "$out" -log() { echo "PHASE $(date +%H:%M:%S) $*" | tee -a "$out/phases.log"; } -skipped() { echo "CHECKED skipped at $(date +%s)" >> "$out/timing.txt"; } -fail() { log "PREP FAILED: $*"; skipped; exit 1; } -device_check() { log "device check: $(timeout -k 5 10 node "$THUMBJAR_RUN_CHECK" "$out/timing.txt" || echo unchecked)"; } - -booted=$(date +%s) -echo "BOOTED at $booted" | tee -a "$out/timing.txt" -tests_seconds=$(timeout -k 5 10 node "$THUMBJAR_RUN_CAP" "$page_dir/../$file") -[[ $tests_seconds =~ ^[0-9]+$ ]] || fail "read the test stage timeout for $file" -log "http server" -pnpm exec thumbjar serve "$page_dir" --port "$port" < /dev/null > "$out/http.log" 2>&1 & -server=$! -trap 'kill $server 2>/dev/null' EXIT - -earlier_phases=$(cat "$out/phases.log" 2>/dev/null | wc -l) -pnpm exec thumbjar prepare --keyboard "$lang" --page-url "http://localhost:$port/editor.html" --out-dir "$out" --booted "$booted" -prep_status=$? -if [ "$prep_status" -ne 0 ]; then - # The prep's own reason, for the job summary. A retry's `phases.log` still holds the earlier boot's - # lines, so only the lines this prep appended count. The retry keys on the prep's status, never on the - # reason, and only together with the device check below. - reason=$(tail -n +$((earlier_phases + 1)) "$out/phases.log" 2>/dev/null | grep -o 'PREP FAILED: .*' | tail -n 1) - [ -n "$reason" ] && echo "$reason" >> "$out/timing.txt" - [ "$prep_status" -eq 3 ] && echo "PREP DICTIONARY_NOT_LOADED" >> "$out/timing.txt" - device_check - exit 1 -fi - -# Records through the test stage, its kill-after, the device check and the diagnostics after it. -(timeout -k 5 $((tests_seconds + 10 + 15 + 65)) adb logcat -v time > "$out/logcat.txt" 2>&1 &) - -log "tests start, $file under $tests_seconds seconds" -timeout -k 10 "$tests_seconds" pnpm exec vitest run --config "$config" --project "$project" "$file" < /dev/null 2>&1 | tee -a "$out/vitest.log" -status=${PIPESTATUS[0]} -log "tests rc=$status" -if [ "$status" -eq 0 ]; then skipped; else device_check; fi - -timeout -k 5 60 bash -c ' - adb logcat -d -b crash > "$1/logcat-crash.txt" - adb shell dumpsys activity exit-info com.android.chrome > "$1/chrome-exit-info.txt" - adb exec-out screencap -p > "$1/final.png" -' _ "$out" || log "diagnostics after the tests rc=$?" -echo "DONE at $(date +%s)" | tee -a "$out/timing.txt" -exit $status diff --git a/packages/thumbjar/src/run/run.test.ts b/packages/thumbjar/src/run/run.test.ts index fac1336777..ce892d37f9 100644 --- a/packages/thumbjar/src/run/run.test.ts +++ b/packages/thumbjar/src/run/run.test.ts @@ -1,5 +1,6 @@ -import {spawn, spawnSync} from 'node:child_process' +import * as childProcesses from 'node:child_process' import { + appendFileSync, chmodSync, existsSync, mkdirSync, @@ -8,31 +9,191 @@ import { rmSync, writeFileSync, } from 'node:fs' +import {Module} from 'node:module' import {tmpdir} from 'node:os' import {join} from 'node:path' import {fileURLToPath} from 'node:url' -import {describe, expect, test, vi} from 'vitest' -import {deviceTestFileSeconds} from '../device/budget.ts' +import {describe, expect, onTestFinished, test, vi} from 'vitest' +import { + deviceTestFileSeconds, + RUN_TEST_KILL_SECONDS, + COMMAND_READ_SECONDS, + COMMAND_KILL_SECONDS, + DIAGNOSTICS_SECONDS, +} from '../device/budget.ts' +import {processAlive} from '../process.test-support.ts' +import * as processes from '../process.ts' +import {run} from './run.ts' + +vi.mock('node:child_process', async (importOriginal) => ({ + ...(await importOriginal()), +})) -const command = fileURLToPath(new URL('../bin.ts', import.meta.url)) +let lastRunExit: number | null = null // The PREP FAILED line `thumbjar prepare` logs for a real CI boot whose Gboard never loaded the dictionary. const NEVER_LOADED = "Gboard's Japanese dictionary never loaded on this boot: Gboard 15.1.08.726012951-preload-x86_64 logged no `data_loader.cc` `New data is loaded` line for a `mozcdata-.data` file within 360s of boot. It logged `Fallback to minimal engine`, and 2 downloads of `__MOZCPACK_mozcmain_ja_JP` were cancelled. The capture may also lack the line: the main buffer had dropped 2865 of its 9812 lines by the capture's first dump, so the capture may lack lines from boot on, its oldest kept line is from 26.9s after boot, so a load before then would not show. See ja-dictionary.log for the dictionary lines it saw." -const realTee = spawnSync('bash', ['-c', 'command -v tee'], { - encoding: 'utf8', -}).stdout.trim() + +const orphanTestCap = 1 +const orphanRecorderBound = + orphanTestCap + + RUN_TEST_KILL_SECONDS + + COMMAND_READ_SECONDS + + COMMAND_KILL_SECONDS + + DIAGNOSTICS_SECONDS + + COMMAND_KILL_SECONDS + + COMMAND_KILL_SECONDS + +const orphanTestTimeout = (5 + orphanRecorderBound + 1 + 10) * 1000 + +test( + 'bounds every stage after the run process group is killed', + async () => { + const directory = mkdtempSync(join(tmpdir(), 'thumbjar-orphan-')) + const pids = join(directory, 'pids') + const cap = orphanTestCap + const logcatBound = orphanRecorderBound + try { + const bin = join(directory, 'bin') + mkdirSync(bin) + const stage = `import {appendFileSync} from 'node:fs' +import {spawn} from 'node:child_process' +import {createServer} from 'node:net' +const kind=process.argv[2] +appendFileSync(${JSON.stringify(pids)},kind+' '+process.pid+'\\n') +if(kind==='prepare') process.exit(0) +if(kind==='vitest') spawn(process.execPath,[${JSON.stringify(join(directory, 'stage.mjs'))},'descendant'],{stdio:'ignore'}) +if(kind==='serve')createServer().listen(0,'127.0.0.1') +if(kind!=='serve') process.on('SIGTERM',()=>{}) +setInterval(()=>{},1000) +` + const stageFile = join(directory, 'stage.mjs') + writeFileSync(stageFile, stage) + const loader = join(directory, 'loader.mjs') + writeFileSync( + loader, + `import {registerHooks} from 'node:module' +registerHooks({load(url,context,next){if(url.endsWith('/bin.ts') && ['serve','prepare'].includes(process.argv[2]))return {format:'module',shortCircuit:true,source:${JSON.stringify(`await import(${JSON.stringify(stageFile)})`)}};if(url.endsWith('/run/cap.ts'))return {format:'module',shortCircuit:true,source:'process.stdout.write("${cap}")'};return next(url,context)}}) +`, + ) + const vitest = join(directory, 'node_modules/vitest') + mkdirSync(vitest, {recursive: true}) + writeFileSync( + join(vitest, 'package.json'), + JSON.stringify({bin: './vitest.mjs'}), + ) + writeFileSync( + join(vitest, 'vitest.mjs'), + `process.argv[2]='vitest';await import(${JSON.stringify(stageFile)})`, + ) + writeFileSync( + join(bin, 'adb'), + `#!${process.execPath} +const fs=require('node:fs') +if(process.argv.slice(2).join(' ')==='logcat -v time') {fs.appendFileSync(${JSON.stringify(pids)},'logcat '+process.pid+' '+process.ppid+'\\n');setInterval(()=>{},1000)} +`, + ) + chmodSync(join(bin, 'adb'), 0o755) + const bootstrap = join(directory, 'run.mjs') + writeFileSync( + bootstrap, + `process.argv=[process.argv[0],${JSON.stringify(fileURLToPath(new URL('../bin.ts', import.meta.url)))},'run','foo','--keyboard','en','--page-dir','.','--config','foo','--project','foo','--out-dir','out'];await import(${JSON.stringify(fileURLToPath(new URL('../bin.ts', import.meta.url)))}) +`, + ) + const child = childProcesses.spawn( + process.execPath, + ['--import', loader, bootstrap], + { + cwd: directory, + detached: true, + stdio: 'ignore', + env: { + ...process.env, + PATH: `${bin}:${process.env.PATH}`, + NODE_PATH: '', + THUMBJAR_KEYBOARD: undefined, + THUMBJAR_OUT_DIR: undefined, + THUMBJAR_PAGE_URL: undefined, + THUMBJAR_PAGE_PORT: undefined, + THUMBJAR_BOOTED: undefined, + NODE_OPTIONS: `--import=${loader}`, + }, + }, + ) + await vi.waitFor( + () => { + const started = readFileSync(pids, 'utf8') + .trim() + .split('\n') + .map((line) => line.split(' ')[0]) + expect( + ['serve', 'vitest', 'descendant', 'logcat'].every((name) => + started.includes(name), + ), + ).toEqual(true) + }, + {timeout: 5000}, + ) + const killedAt = Date.now() + const remaining = (seconds: number) => + Math.max(1, killedAt + seconds * 1000 + 1000 - Date.now()) + process.kill(-child.pid!, 'SIGKILL') + const started = readFileSync(pids, 'utf8') + .trim() + .split('\n') + .map((line) => line.split(' ')) + const serve = started.find(([name]) => name === 'serve')! + await vi.waitFor( + () => expect(processAlive(Number(serve[1]))).toEqual(false), + {timeout: remaining(RUN_TEST_KILL_SECONDS)}, + ) + const stagePids = started + .filter( + ([name]) => + name === 'vitest' || name === 'descendant' || name === 'serve', + ) + .map(([, pid]) => Number(pid)) + await vi.waitFor( + () => expect(stagePids.filter((pid) => processAlive(pid))).toEqual([]), + {timeout: remaining(cap + RUN_TEST_KILL_SECONDS)}, + ) + const recorder = started.find(([name]) => name === 'logcat')! + expect(processAlive(Number(recorder[2]))).toEqual(true) + expect( + readFileSync(`/proc/${recorder[2]}/cmdline`, 'utf8').includes( + '/run/logcat.ts', + ), + ).toEqual(true) + await vi.waitFor( + () => expect(processAlive(Number(recorder[1]))).toEqual(false), + {timeout: remaining(logcatBound)}, + ) + } finally { + if (existsSync(pids)) { + for (const line of readFileSync(pids, 'utf8').trim().split('\n')) { + const pid = Number(line.split(' ')[1]) + try { + process.kill(pid, 'SIGKILL') + } catch {} + } + } + rmSync(directory, {recursive: true, force: true}) + } + }, + orphanTestTimeout, +) describe('thumbjar run', () => { - test('prepares the language it is given and runs only the file it is given, under that file’s cap', () => { + test('prepares the language it is given and runs only the file it is given, under that file’s cap', async () => { expect([ - runOnDevice({ + await controlledRun({ lang: 'ja', file: 'tests/ja.test.ts', prep: 0, tests: 0, getState: 'device', }), - runOnDevice({ + await controlledRun({ lang: 'en', file: 'tests/en-textarea.test.ts', prep: 0, @@ -75,9 +236,9 @@ describe('thumbjar run', () => { ]) }) - test('reads `adb get-state` once the failed test stage ended, and leaves a reachable device unmarked', () => { + test('reads `adb get-state` once the failed test stage ended, and leaves a reachable device unmarked', async () => { expect( - runOnDevice({ + await controlledRun({ lang: 'en', file: 'tests/en.test.ts', prep: 0, @@ -104,9 +265,9 @@ describe('thumbjar run', () => { }) }) - test('marks the device lost when adb has none once the failed test stage ended', () => { + test('marks the device lost when adb has none once the failed test stage ended', async () => { expect( - runOnDevice({ + await controlledRun({ lang: 'en', file: 'tests/en.test.ts', prep: 0, @@ -133,16 +294,16 @@ describe('thumbjar run', () => { }) }) - test('reads `adb get-state` once a failed prep ended, and runs no tests', () => { + test('reads `adb get-state` once a failed prep ended, and runs no tests', async () => { expect([ - runOnDevice({ + await controlledRun({ lang: 'ja', file: 'tests/ja.test.ts', prep: 1, tests: 0, getState: 'device', }), - runOnDevice({ + await controlledRun({ lang: 'ja', file: 'tests/ja.test.ts', prep: 1, @@ -173,16 +334,16 @@ describe('thumbjar run', () => { }) describe('thumbjar run when the test stage cap cannot be read', () => { - test('skips the device check and runs no device work for an unreadable file or one with no tests', () => { + test('skips the device check and runs no device work for an unreadable file or one with no tests', async () => { expect([ - runOnDevice({ + await controlledRun({ lang: 'en', file: 'tests/missing.test.ts', prep: 0, tests: 0, getState: 'device', }), - runOnDevice({ + await controlledRun({ lang: 'en', file: 'tests/empty.test.ts', prep: 0, @@ -211,9 +372,9 @@ describe('thumbjar run when the test stage cap cannot be read', () => { }) describe('thumbjar run after a prep that named its failure', () => { - test("records the prep's reason for the job summary before the device check, and runs no tests", () => { + test("records the prep's reason for the job summary before the device check, and runs no tests", async () => { expect( - runOnDevice({ + await controlledRun({ lang: 'ja', file: 'tests/ja.test.ts', prep: 1, @@ -240,9 +401,9 @@ describe('thumbjar run after a prep that named its failure', () => { }) }) - test("marks a prep that exited 3, Gboard's Japanese dictionary never loaded, after its reason and before the device check, and runs no tests", () => { + test("marks a prep that exited 3, Gboard's Japanese dictionary never loaded, after its reason and before the device check, and runs no tests", async () => { expect( - runOnDevice({ + await controlledRun({ lang: 'ja', file: 'tests/ja.test.ts', prep: 3, @@ -272,9 +433,9 @@ describe('thumbjar run after a prep that named its failure', () => { }) describe('thumbjar run on the retry after adb lost the device', () => { - test("records no reason for a prep that named none, though `phases.log` holds the first boot's", () => { + test("records no reason for a prep that named none, though `phases.log` holds the first boot's", async () => { expect( - runOnDevice({ + await controlledRun({ lang: 'ja', file: 'tests/ja.test.ts', earlierPhases: [ @@ -329,7 +490,6 @@ describe('thumbjar run cut off by its run step', () => { file: 'tests/en.test.ts', stopAt: 'tests', getState: 'device', - kill: 'process', }), ).toEqual({ calls: [ @@ -435,12 +595,66 @@ describe('thumbjar run with a device check that never completes', () => { }, 30_000) }) +test('logs a missing consumer Vitest and completes the failed-stage diagnostics', async () => { + const result = await controlledRun({ + lang: 'en', + file: 'tests/en.test.ts', + prep: 0, + tests: 0, + getState: 'device', + missingVitest: true, + }) + expect({ + status: result.status, + phases: result.phases, + timing: result.timing, + reason: result.vitestReason, + diagnostics: result.diagnostics, + }).toEqual({ + status: 1, + phases: [ + 'http server', + `tests start, tests/en.test.ts under ${deviceTestFileSeconds(3)} seconds`, + 'tests rc=1', + 'device check: device', + ], + timing: ['BOOTED', 'CHECKED device', 'DONE'], + reason: "Unable to start Vitest: Cannot find module 'vitest/package.json'", + diagnostics: true, + }) +}) + +test('returns 1 when run is stopped by SIGTERM', async () => { + await interruptOnDevice({ + lang: 'en', + file: 'tests/en.test.ts', + stopAt: 'tests', + getState: 'device', + signal: 'SIGTERM', + tests: 0, + }) + expect(lastRunExit).toEqual(1) +}, 15_000) + +test('returns 1 when run is stopped by SIGINT', async () => { + await interruptOnDevice({ + lang: 'en', + file: 'tests/en.test.ts', + stopAt: 'tests', + getState: 'device', + signal: 'SIGINT', + tests: 0, + }) + expect(lastRunExit).toEqual(1) +}, 15_000) + function runOnDevice(options: { lang: string file: string /** The lines an earlier boot's run left in `phases.log`, as the retry finds them. */ earlierPhases?: string[] prep: number + missingVitest?: boolean /** The PREP FAILED line's reason the fake prep logs to `phases.log`, as `thumbjar prepare` does. */ prepFailure?: string tests: number @@ -461,7 +675,7 @@ function runOnDevice(options: { ) } writeFileSync( - join(bin, 'pnpm'), + join(bin, 'commands.sh'), `#!/usr/bin/env bash case "$*" in "exec thumbjar serve"*) exit 0 ;; @@ -488,13 +702,13 @@ case "$*" in esac `, ) - chmodSync(join(bin, 'pnpm'), 0o755) + chmodSync(join(bin, 'commands.sh'), 0o755) chmodSync(join(bin, 'adb'), 0o755) - const result = spawnSync( + const result = childProcesses.spawnSync( process.execPath, [ - command, + runBootstrap(directory, options), 'run', options.file, '--keyboard', @@ -514,6 +728,13 @@ esac env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, + NODE_PATH: '', + THUMBJAR_KEYBOARD: undefined, + THUMBJAR_OUT_DIR: undefined, + THUMBJAR_PAGE_URL: undefined, + THUMBJAR_PAGE_PORT: undefined, + THUMBJAR_BOOTED: undefined, + NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ''} --import=${join(directory, 'hooks.mjs')}`, }, }, ) @@ -523,6 +744,14 @@ esac )?.[1] return { status: result.status, + ...(options.missingVitest + ? { + vitestReason: readFileSync(join(out, 'vitest.log'), 'utf8').split( + '\n', + )[0], + diagnostics: existsSync(join(out, 'final.png')), + } + : {}), calls: existsSync(calls) ? readFileSync(calls, 'utf8') .trimEnd() @@ -550,111 +779,110 @@ async function interruptOnDevice(options: { file: string stopAt: 'tests' | 'device check log' getState: 'device' | 'none' - // The runner's step timeout stops the whole process group, a plain `kill` only the `thumbjar` process. - kill?: 'group' | 'process' + signal?: 'SIGTERM' | 'SIGINT' + tests?: number }) { - const directory = mkdtempSync(join(tmpdir(), 'thumbjar-run-')) - try { - const bin = join(directory, 'bin') - const calls = join(directory, 'calls.log') - const stopped = join(directory, 'stopped') - deviceTree(directory) - writeFileSync( - join(bin, 'pnpm'), - `#!/usr/bin/env bash -case "$*" in - "exec thumbjar serve"*) exit 0 ;; - "exec thumbjar prepare"*) echo "pnpm $*" >> ${calls}; exit 0 ;; - "exec vitest"*) echo "pnpm $*" >> ${calls}; ${options.stopAt === 'tests' ? `touch ${stopped}; exec sleep ${options.kill === 'process' ? 2 : 60}` : 'exit 1'} ;; -esac + const signals = (['SIGTERM', 'SIGINT', 'SIGHUP'] as const).map((signal) => ({ + signal, + handlers: process.listeners(signal), + })) + onTestFinished(() => { + try { + for (const {signal, handlers} of signals) { + expect( + process + .listeners(signal) + .every((handler) => handlers.includes(handler)), + ).toEqual(true) + } + } finally { + for (const {signal, handlers} of signals) { + for (const handler of process.listeners(signal)) { + if (!handlers.includes(handler)) { + process.off(signal, handler) + } + } + } + } + }) + const result = await controlledRun( + {...options, prep: 0, tests: options.tests ?? 1}, + options, + ) + lastRunExit = result.status + return {calls: result.calls, timing: result.timing} +} + +function runBootstrap( + directory: string, + options: { + lang: string + file: string + missingVitest?: boolean + }, +) { + writeFileSync( + join(directory, 'environment.mjs'), + `import {deepStrictEqual} from 'node:assert' +const keys=['THUMBJAR_RUN_CAP','THUMBJAR_RUN_CHECK','THUMBJAR_KEYBOARD','THUMBJAR_OUT_DIR','THUMBJAR_PAGE_URL','THUMBJAR_PAGE_PORT','THUMBJAR_BOOTED'] +deepStrictEqual(Object.fromEntries(keys.map(key=>[key,process.env[key]??null])),{ + THUMBJAR_RUN_CAP:${JSON.stringify(fileURLToPath(new URL('cap.ts', import.meta.url)))}, + THUMBJAR_RUN_CHECK:${JSON.stringify(fileURLToPath(new URL('check.ts', import.meta.url)))}, + THUMBJAR_KEYBOARD:null,THUMBJAR_OUT_DIR:null,THUMBJAR_PAGE_URL:null,THUMBJAR_PAGE_PORT:null,THUMBJAR_BOOTED:null +}) `, - ) - writeFileSync( - join(bin, 'adb'), - `#!/usr/bin/env bash -case "$*" in - "logcat -v time") exit 0 ;; - get-state) echo "adb $*" >> ${calls}; ${getStateAnswer(options.getState)} ;; - *) echo "adb $*" >> ${calls} ;; -esac + ) + const dispatch = join(directory, 'dispatch.mjs') + const fixture = join(directory, 'bin/commands.sh') + writeFileSync( + dispatch, + `import ${JSON.stringify(join(directory, 'environment.mjs'))} +import {spawnSync} from 'node:child_process' +import {deepStrictEqual} from 'node:assert' +import {join, relative} from 'node:path' +const args=process.argv.slice(2) +const out=join(process.cwd(),'android/out') +if(args[0]==='serve'){ + deepStrictEqual(args[1],join(process.cwd(),'android/dist')) + args[1]=relative(process.cwd(),args[1]) +}else{ + const index=args.indexOf('--out-dir')+1 + deepStrictEqual(args[index],out) + args[index]=relative(process.cwd(),args[index]) +} +const result = spawnSync('/bin/bash', [${JSON.stringify(fixture)}, 'exec', 'thumbjar', ...args], {stdio: 'inherit'}) +process.exit(result.status ?? 1) `, - ) - // Holds the `device check:` log line, the one write after the check, until the step stops the script. - writeFileSync( - join(bin, 'tee'), - `#!/usr/bin/env bash -line=$(cat) -case "$line" in - *"device check:"*) ${options.stopAt === 'device check log' ? `touch ${stopped}; exec sleep 60` : ''} ;; -esac -printf '%s\\n' "$line" | ${realTee} "$@" + ) + const vitest = join(directory, 'node_modules/vitest') + mkdirSync(vitest, {recursive: true}) + writeFileSync( + join(vitest, 'package.json'), + JSON.stringify({name: 'vitest', bin: './vitest.mjs'}), + ) + writeFileSync( + join(vitest, 'vitest.mjs'), + `import ${JSON.stringify(join(directory, 'environment.mjs'))} +import {spawnSync} from 'node:child_process' +const result = spawnSync('/bin/bash', [${JSON.stringify(fixture)}, 'exec', 'vitest', ...process.argv.slice(2)], {stdio: 'inherit'}) +process.exit(result.status ?? 1) `, - ) - chmodSync(join(bin, 'pnpm'), 0o755) - chmodSync(join(bin, 'adb'), 0o755) - chmodSync(join(bin, 'tee'), 0o755) - - const script = spawn( - process.execPath, - [ - command, - 'run', - options.file, - '--keyboard', - options.lang, - '--page-dir', - 'android/dist', - '--config', - 'vitest.android.config.ts', - '--project', - 'device', - '--out-dir', - 'android/out', - ], - { - cwd: directory, - detached: true, - stdio: 'ignore', - env: { - ...process.env, - PATH: `${bin}:${process.env.PATH}`, - }, - }, - ) - const exited = new Promise((resolve) => script.once('exit', resolve)) - await vi.waitFor( - () => { - expect(existsSync(stopped)).toEqual(true) - }, - {timeout: 10_000}, - ) - if (options.kill === 'process') { - process.kill(script.pid ?? 0, 'SIGTERM') - await exited - // A script that outlived its `thumbjar` process would go on once the tests ended. - await new Promise((resolve) => setTimeout(resolve, 3_500)) - } else { - // The emulator runner's step timeout kills the script's whole process group. - process.kill(-(script.pid ?? 0), 'SIGKILL') - await exited - } - - const booted = /^BOOTED at (\d+)$/m.exec( - readFileSync(join(directory, 'android/out/timing.txt'), 'utf8'), - )?.[1] - return { - calls: readFileSync(calls, 'utf8') - .trimEnd() - .split('\n') - .map((call) => call.replace(` --booted ${booted}`, ' --booted BOOTED')), - timing: readFileSync(join(directory, 'android/out/timing.txt'), 'utf8') - .trimEnd() - .split('\n') - .map((line) => line.replace(/ at \d+$/, '')), - } - } finally { - rmSync(directory, {recursive: true, force: true}) + ) + if (options.missingVitest) { + rmSync(vitest, {recursive: true, force: true}) } + const main = fileURLToPath(new URL('../bin.ts', import.meta.url)) + const hooks = join(directory, 'hooks.mjs') + writeFileSync( + hooks, + `import {registerHooks} from 'node:module' +registerHooks({load(url,context,next){ + if(url===${JSON.stringify(new URL('../bin.ts', import.meta.url).href)} && ['serve','prepare'].includes(process.argv[2])) return {format:'module',shortCircuit:true,source:${JSON.stringify(`await import(${JSON.stringify(dispatch)})`)}} + return next(url,context) +}}) +`, + ) + return main } function shellString(text: string) { @@ -686,3 +914,254 @@ function deviceTree(directory: string) { ) } } + +async function controlledRun( + options: Parameters[0], + interruption?: { + stopAt: 'tests' | 'device check log' + signal?: 'SIGTERM' | 'SIGINT' + }, +) { + const reached = Promise.withResolvers() + const failure = Promise.withResolvers() + let heldStageAborted = false + let holdingStage = false + let removeHeldAbort = () => {} + let running: ReturnType | undefined + const signal = interruption?.signal ?? 'SIGTERM' + const earlierHandlers = new Set(process.listeners(signal)) + const directory = mkdtempSync(join(tmpdir(), 'thumbjar-run-')) + const out = join(directory, 'android/out') + const calls: string[] = [] + const argv = process.argv + const exitCode = process.exitCode + try { + deviceTree(directory) + runBootstrap(directory, options) + mkdirSync(out, {recursive: true}) + if (options.earlierPhases !== undefined) { + writeFileSync( + join(out, 'phases.log'), + options.earlierPhases + .map((line) => `PHASE 00:00:00 ${line}\n`) + .join(''), + ) + } + for (const name of [ + 'THUMBJAR_KEYBOARD', + 'THUMBJAR_OUT_DIR', + 'THUMBJAR_PAGE_URL', + 'THUMBJAR_PAGE_PORT', + 'THUMBJAR_BOOTED', + ]) { + vi.stubEnv(name, undefined) + } + vi.stubEnv('NODE_PATH', '') + Reflect.get(Module, '_initPaths')() + vi.spyOn(process, 'cwd').mockReturnValue(directory) + vi.spyOn(childProcesses, 'spawn').mockReturnValue( + new childProcesses.ChildProcess(), + ) + vi.spyOn(childProcesses, 'spawnSync').mockImplementation( + (command, args = []) => { + expect([command, ...args]).toEqual(['adb', 'get-state']) + calls.push(`${command} ${args.join(' ')}`) + return { + pid: 0, + output: [null, null, null], + status: options.getState === 'device' ? 0 : 1, + signal: null, + stdout: options.getState === 'device' ? 'device\n' : '', + stderr: `* daemon not running; starting now at tcp:5037\n${ + options.getState === 'device' + ? '' + : 'error: no devices/emulators found\n' + }`, + } + }, + ) + vi.spyOn(processes, 'execute').mockImplementation( + async (command, args, settings) => { + settings.signal?.throwIfAborted() + expect(settings.env?.THUMBJAR_RUN_CAP).toEqual( + processes.entry('run/cap'), + ) + expect(settings.env?.THUMBJAR_RUN_CHECK).toEqual( + processes.entry('run/check'), + ) + for (const name of [ + 'THUMBJAR_KEYBOARD', + 'THUMBJAR_OUT_DIR', + 'THUMBJAR_PAGE_URL', + 'THUMBJAR_PAGE_PORT', + 'THUMBJAR_BOOTED', + ]) { + expect(settings.env?.[name]).toEqual(undefined) + } + let stdout = '' + let status = 0 + if ( + args[0] === processes.entry('run/cap') || + args[0] === processes.entry('run/check') + ) { + const checker = args[0] === processes.entry('run/check') + process.argv = [process.execPath, ...args] + const output = vi + .spyOn(process.stdout, 'write') + .mockImplementation((chunk) => { + stdout += chunk.toString() + return true + }) + try { + vi.resetModules() + if (checker) { + await import('./check.ts') + } else { + await import('./cap.ts') + } + } catch (error) { + if (checker) { + throw error + } + status = 1 + } finally { + output.mockRestore() + process.argv = argv + } + } else if (args[1] === 'serve') { + expect(args).toEqual([ + processes.entry('bin'), + 'serve', + join(directory, 'android/dist'), + '--port', + '8765', + ]) + } else if (args[1] === 'prepare') { + expect(args[args.indexOf('--out-dir') + 1]).toEqual(out) + calls.push( + `pnpm exec thumbjar ${args + .slice(1) + .join(' ') + .replace(out, 'android/out') + .replace(/ --booted \d+$/, ' --booted BOOTED')}`, + ) + if (options.prepFailure !== undefined) { + appendFileSync( + join(out, 'phases.log'), + `PHASE 00:00:00 PREP FAILED: ${options.prepFailure}\n`, + ) + } + status = options.prep + } else if (command === 'adb') { + calls.push(`adb ${args.join(' ')}`) + } else { + calls.push(`pnpm exec vitest ${args.slice(1).join(' ')}`) + status = options.tests + if (interruption !== undefined && settings.signal === undefined) { + const error = new Error( + 'test stage execute did not receive the run abort signal', + ) + error.name = 'MissingTestStageAbortSignal' + failure.reject(error) + throw error + } + } + if ( + interruption !== undefined && + (interruption.stopAt === 'device check log' + ? args[0] === processes.entry('run/check') + : command !== 'adb' && + args[0] !== processes.entry('run/cap') && + args[1] !== 'serve' && + args[1] !== 'prepare') + ) { + const stageSignal = settings.signal + if (stageSignal === undefined) { + const error = new Error( + 'held execute did not receive the run abort signal', + ) + error.name = 'MissingHeldStageAbortSignal' + failure.reject(error) + throw error + } + stageSignal.throwIfAborted() + holdingStage = true + await new Promise((_resolve, reject) => { + const abort = () => { + heldStageAborted = true + holdingStage = false + reject(stageSignal.reason) + } + stageSignal.addEventListener('abort', abort, {once: true}) + removeHeldAbort = () => + stageSignal.removeEventListener('abort', abort) + reached.resolve() + }) + } + return {status, stdout, stderr: ''} + }, + ) + running = run({ + file: options.file, + keyboard: options.lang, + pageDir: join(directory, 'android/dist'), + config: 'vitest.android.config.ts', + project: 'device', + port: '8765', + out, + }) + if (interruption !== undefined) { + await Promise.race([ + reached.promise, + failure.promise, + running.then(() => { + throw new Error('run finished before reaching the held stage') + }), + ]) + const handlers = process + .listeners(signal) + .filter((handler) => !earlierHandlers.has(handler)) + expect(handlers.length).toEqual(1) + handlers[0]!.call(process, signal) + expect(heldStageAborted).toEqual(true) + } + const status = await running + return { + status, + ...(options.missingVitest + ? { + vitestReason: readFileSync(join(out, 'vitest.log'), 'utf8').split( + '\n', + )[0], + diagnostics: existsSync(join(out, 'final.png')), + } + : {}), + calls, + phases: readFileSync(join(out, 'phases.log'), 'utf8') + .trimEnd() + .split('\n') + .map((line) => line.replace(/^PHASE \d\d:\d\d:\d\d /, '')), + timing: readFileSync(join(out, 'timing.txt'), 'utf8') + .trimEnd() + .split('\n') + .map((line) => line.replace(/ at \d+$/, '')), + } + } finally { + const handlers = process + .listeners(signal) + .filter((handler) => !earlierHandlers.has(handler)) + for (const handler of handlers) { + handler.call(process, signal) + } + removeHeldAbort() + if (!holdingStage) { + await running?.catch(() => {}) + } + process.argv = argv + process.exitCode = exitCode + vi.restoreAllMocks() + vi.unstubAllEnvs() + Reflect.get(Module, '_initPaths')() + rmSync(directory, {recursive: true, force: true}) + } +} diff --git a/packages/thumbjar/src/run/run.ts b/packages/thumbjar/src/run/run.ts new file mode 100644 index 0000000000..c674496ab2 --- /dev/null +++ b/packages/thumbjar/src/run/run.ts @@ -0,0 +1,275 @@ +import {spawn} from 'node:child_process' +import {appendFileSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs' +import {createRequire} from 'node:module' +import {dirname, join, resolve} from 'node:path' +import { + COMMAND_READ_SECONDS, + COMMAND_KILL_SECONDS, + RUN_TEST_KILL_SECONDS, + PREP_KILL_SECONDS, + DIAGNOSTICS_SECONDS, + RUN_SERVER_KILL_SECONDS, +} from '../device/budget.ts' +import { + Deadline, + entry, + execute as executeChild, + termination, + within, +} from '../process.ts' + +export async function run(options: { + file: string + keyboard: string + pageDir: string + config: string + project: string + port: string + out: string +}): Promise { + const {file, keyboard, pageDir, config, project, port, out} = options + const env = { + ...process.env, + THUMBJAR_RUN_CAP: entry('run/cap'), + THUMBJAR_RUN_CHECK: entry('run/check'), + } + const execute: typeof executeChild = (command, args, options) => + executeChild(command, args, {...options, env}) + mkdirSync(out, {recursive: true}) + const stop = termination() + const serverStop = new AbortController() + const command = entry('bin') + const timing = (line: string, print = false) => { + const text = `${line}\n` + appendFileSync(join(out, 'timing.txt'), text) + if (print) { + process.stdout.write(text) + } + } + const log = (message: string) => { + stop.signal.throwIfAborted() + const now = new Date() + const time = [now.getHours(), now.getMinutes(), now.getSeconds()] + .map((value) => String(value).padStart(2, '0')) + .join(':') + const line = `PHASE ${time} ${message}\n` + appendFileSync(join(out, 'phases.log'), line) + process.stdout.write(line) + } + const skipped = () => timing(`CHECKED skipped at ${epoch()}`) + const deviceCheck = async () => { + const checked = await execute( + process.execPath, + [entry('run/check'), join(out, 'timing.txt')], + { + seconds: COMMAND_READ_SECONDS, + killAfter: COMMAND_KILL_SECONDS, + signal: stop.signal, + stderr: (chunk) => process.stderr.write(chunk), + }, + ) + await log( + `device check: ${checked.stdout.trimEnd()}${checked.status !== 0 ? `${checked.stdout ? '\n' : ''}unchecked` : ''}`, + ) + } + let server: Promise | undefined + try { + const booted = epoch() + timing(`BOOTED at ${booted}`, true) + const cap = await execute( + process.execPath, + [entry('run/cap'), join(pageDir, '..', file)], + { + seconds: COMMAND_READ_SECONDS, + killAfter: COMMAND_KILL_SECONDS, + signal: stop.signal, + stderr: (chunk) => process.stderr.write(chunk), + }, + ) + const seconds = cap.stdout.trimEnd() + if (!/^\d+$/.test(seconds)) { + await log(`PREP FAILED: read the test stage timeout for ${file}`) + skipped() + return 1 + } + await log('http server') + const httpOutput = (chunk: Buffer) => + appendFileSync(join(out, 'http.log'), chunk) + writeFileSync(join(out, 'http.log'), '') + server = execute( + process.execPath, + [command, 'serve', pageDir, '--port', port], + { + killAfter: RUN_SERVER_KILL_SECONDS, + signal: AbortSignal.any([stop.signal, serverStop.signal]), + stdout: httpOutput, + stderr: httpOutput, + }, + ).catch(() => {}) + const earlier = read(join(out, 'phases.log')).length + const prepared = await execute( + process.execPath, + [ + command, + 'prepare', + '--keyboard', + keyboard, + '--page-url', + `http://localhost:${port}/editor.html`, + '--out-dir', + out, + '--booted', + String(booted), + ], + { + killAfter: PREP_KILL_SECONDS, + signal: stop.signal, + stdout: (chunk) => process.stdout.write(chunk), + stderr: (chunk) => process.stderr.write(chunk), + }, + ) + if (prepared.status !== 0) { + const reason = read(join(out, 'phases.log')) + .subarray(earlier) + .toString() + .split('\n') + .map((line) => /PREP FAILED: .*/.exec(line)?.[0]) + .filter((line) => line !== undefined) + .at(-1) + if (reason) { + timing(reason) + } + if (prepared.status === 3) { + timing('PREP DICTIONARY_NOT_LOADED') + } + await deviceCheck() + return 1 + } + const capture = spawn( + process.execPath, + [ + entry('run/logcat'), + String( + Number(seconds) + + RUN_TEST_KILL_SECONDS + + COMMAND_READ_SECONDS + + COMMAND_KILL_SECONDS + + DIAGNOSTICS_SECONDS + + COMMAND_KILL_SECONDS, + ), + join(out, 'logcat.txt'), + ], + {detached: true, stdio: 'ignore', env}, + ) + capture.unref() + await log(`tests start, ${file} under ${seconds} seconds`) + appendFileSync(join(out, 'vitest.log'), '') + const testOutput = (chunk: Buffer) => { + appendFileSync(join(out, 'vitest.log'), chunk) + process.stdout.write(chunk) + } + let tests = {status: 1} + try { + tests = await execute( + process.execPath, + [vitestBin(), 'run', '--config', config, '--project', project, file], + { + seconds: Number(seconds), + killAfter: RUN_TEST_KILL_SECONDS, + signal: stop.signal, + stdout: testOutput, + stderr: testOutput, + }, + ) + } catch (error) { + stop.signal.throwIfAborted() + testOutput( + Buffer.from( + `Unable to start Vitest: ${error instanceof Error ? error.message : String(error)}\n`, + ), + ) + } + await log(`tests rc=${tests.status}`) + if (tests.status === 0) { + skipped() + } else { + await deviceCheck() + } + try { + await within( + DIAGNOSTICS_SECONDS, + COMMAND_KILL_SECONDS, + stop.signal, + async (signal) => { + for (const [name, ...args] of [ + ['logcat-crash.txt', 'logcat', '-d', '-b', 'crash'], + [ + 'chrome-exit-info.txt', + 'shell', + 'dumpsys', + 'activity', + 'exit-info', + 'com.android.chrome', + ], + ['final.png', 'exec-out', 'screencap', '-p'], + ]) { + writeFileSync(join(out, name!), '') + const result = await execute('adb', args, { + seconds: DIAGNOSTICS_SECONDS, + killAfter: COMMAND_KILL_SECONDS, + signal, + stdout: (chunk) => appendFileSync(join(out, name!), chunk), + stderr: (chunk) => process.stderr.write(chunk), + }) + if (name === 'final.png' && result.status !== 0) { + await log(`diagnostics after the tests rc=${result.status}`) + } + } + }, + ) + } catch (error) { + stop.signal.throwIfAborted() + if (!(error instanceof Deadline)) { + throw error + } + await log(`diagnostics after the tests rc=${error.status}`) + } + timing(`DONE at ${epoch()}`, true) + return tests.status + } catch (error) { + if (stop.signal.aborted) { + return 1 + } + throw error + } finally { + serverStop.abort() + await server + stop.dispose() + } +} + +function epoch() { + return Math.floor(Date.now() / 1000) +} + +function read(file: string): Buffer { + try { + return readFileSync(file) + } catch { + return Buffer.alloc(0) + } +} + +function vitestBin() { + const require = createRequire(join(process.cwd(), 'package.json')) + const manifestPath = require.resolve('vitest/package.json') + const manifest: {bin: string | Record} = JSON.parse( + readFileSync(manifestPath, 'utf8'), + ) + const bin = + typeof manifest.bin === 'string' ? manifest.bin : manifest.bin.vitest + if (!bin) { + throw new Error('vitest has no bin') + } + return resolve(dirname(manifestPath), bin) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 60435d71fe..dfd282c173 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -216,8 +216,8 @@ importers: specifier: workspace:* version: link:../../packages/keyboard-shortcuts '@portabletext/markdown': - specifier: ^2.9.3 - version: 2.9.3 + specifier: ^2.9.4 + version: 2.9.4 '@portabletext/patches': specifier: workspace:* version: link:../../packages/patches @@ -454,8 +454,8 @@ importers: specifier: workspace:* version: link:../../packages/keyboard-shortcuts '@portabletext/markdown': - specifier: ^2.9.3 - version: 2.9.3 + specifier: ^2.9.4 + version: 2.9.4 '@portabletext/patches': specifier: workspace:* version: link:../../packages/patches @@ -1653,6 +1653,10 @@ importers: version: 5.0.5(@types/node@24.12.2)(@vitest/browser-playwright@5.0.5)(@vitest/coverage-istanbul@5.0.5)(@vitest/coverage-v8@5.0.5)(@vitest/ui@5.0.5)(jsdom@27.2.0)(vite@8.3.4(@types/node@24.12.2)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.44.1)(tsx@4.23.12)(yaml@2.9.1)) packages/thumbjar: + dependencies: + find-cache-directory: + specifier: ^6.0.0 + version: 6.0.0 devDependencies: '@sanity/pkg-utils': specifier: catalog:tooling @@ -4571,8 +4575,8 @@ packages: '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} - '@portabletext/markdown@2.9.3': - resolution: {integrity: sha512-oDx5zwHCdCiHZ9xRbBDh5nALUORy2ZCoW+OpZSTpNt69agA8jrdUktl+74qWKdE1BBra7Jj2tLDgFpl5m0Rilw==} + '@portabletext/markdown@2.9.4': + resolution: {integrity: sha512-BMR7UF19QB7qbZBzG2FeSjTlE8AV4HFJq/XufLVfGzX8v9Qd7s1eetpQEKSHssjrmxXHmmqo0KKoiUhfRaCU9w==} engines: {node: '>=22.12'} '@portabletext/react@8.0.1': @@ -7171,6 +7175,9 @@ packages: resolution: {integrity: sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==} engines: {node: '>= 18'} + common-path-prefix@3.0.0: + resolution: {integrity: sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==} + commondir@1.0.1: resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} @@ -7691,6 +7698,10 @@ packages: resolution: {integrity: sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==} engines: {node: '>=6'} + find-cache-directory@6.0.0: + resolution: {integrity: sha512-CvFd5ivA6HcSHbD+59P7CyzINHXzwhuQK8RY7CxJZtgDSAtRlHiCaQpZQ2lMR/WRyUIEmzUvL6G2AGurMfegZA==} + engines: {node: '>=20'} + find-config@1.0.0: resolution: {integrity: sha512-Z+suHH+7LSE40WfUeZPIxSxypCWvrzdVc60xAjUShZeT5eMWM0/FQUduq3HjluyfAHWvC/aOBkT1pTZktyF/jg==} engines: {node: '>= 0.12'} @@ -9335,6 +9346,10 @@ packages: resolution: {integrity: sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==} engines: {node: '>=6'} + pkg-dir@8.0.0: + resolution: {integrity: sha512-4peoBq4Wks0riS0z8741NVv+/8IiTvqnZAr8QGgtdifrtpdXbNw/FxRS1l6NFqm4EMzuS0EDqNNx4XGaz8cuyQ==} + engines: {node: '>=18'} + pkg-pr-new@0.0.88: resolution: {integrity: sha512-Xc6PMJ2gher0WZP+rtjefFk26hIb7V1PTLL30bmy1Z2vsRmSvqiss7Ag1XUdyplTGYzIlFNJp4L3vMNmK44N6g==} hasBin: true @@ -14241,7 +14256,7 @@ snapshots: '@polka/url@1.0.0-next.29': {} - '@portabletext/markdown@2.9.3': + '@portabletext/markdown@2.9.4': dependencies: '@mdit/plugin-alert': 2.0.1(markdown-it@15.0.3) '@portabletext/schema': link:packages/schema @@ -17282,6 +17297,8 @@ snapshots: common-ancestor-path@2.0.0: {} + common-path-prefix@3.0.0: {} + commondir@1.0.1: {} compute-scroll-into-view@3.1.1: {} @@ -17801,6 +17818,11 @@ snapshots: make-dir: 2.1.0 pkg-dir: 3.0.0 + find-cache-directory@6.0.0: + dependencies: + common-path-prefix: 3.0.0 + pkg-dir: 8.0.0 + find-config@1.0.0: dependencies: user-home: 2.0.0 @@ -19874,6 +19896,10 @@ snapshots: dependencies: find-up: 3.0.0 + pkg-dir@8.0.0: + dependencies: + find-up-simple: 1.0.1 + pkg-pr-new@0.0.88: {} player.style@0.3.4(react@19.3.1):