mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 16:02:24 +00:00
fix(runtime): terminate nonblank tail scan at the first row
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
# Terminal wait tail-window termination
|
||||
|
||||
`startOfLastNonBlankLines` loops indefinitely if its input begins with a newline and contains fewer nonblank rows than requested. Once the backward cursor reaches zero, JavaScript `lastIndexOf` clamps its negative start position to zero and rediscovers the same first newline. The cursor stops advancing.
|
||||
|
||||
The fix ends the scan when the cursor reaches zero and returns the existing short-tail offset, zero. It changes no prompt patterns or readiness rules. The ordinary finite-window selection cases retain their previous offsets.
|
||||
|
||||
## Reproduce
|
||||
|
||||
```sh
|
||||
ORCA_BACKGROUND_LAUNCH=1 node docs/audits/terminal-wait-leading-blank/reproduce.mjs > /tmp/orca-terminal-wait-leading-blank.json
|
||||
```
|
||||
|
||||
The script bundles actual source and reverses only the two-line termination change for the baseline. Each case runs in an isolated child with a two-second deadline and 128 MiB heap limit. The parent confirms child termination; no app windows open. Five direct helper/detector cases time out before and return after. A sufficient-row control and actual headless terminal projection controls pass in both variants. Results include source hashes, platform, timing, and an explicit v1.4.198 helper comparison.
|
||||
|
||||
The regression suite uses a separate child for inputs that could hang the worker. It also tests exact row offsets with intervening whitespace, trailing blanks and tails shorter than the requested window. Fifty helper/detector tests pass.
|
||||
|
||||
## Production and incident limits
|
||||
|
||||
The helper is byte-identical in v1.4.198. However, all inspected main/provider/renderer visible-screen projection routes pass through `visibleNonBlankTerminalLines`, and ordinary retained-tail construction removes blank rows too. The real headless producer control confirms this filtering. Calling the public detector directly with a leading newline is therefore insufficient evidence that those production routes trigger the defect.
|
||||
|
||||
A clipped 300-character preview can start at a newline. The preview fallback also preserves it when passed empty retained rows; the proof records both facts. It does not establish an actual application lifecycle that combines that fallback with a live detector call. That remains unproven.
|
||||
|
||||
This is a defensive termination fix found during the memory audit. The loop itself does not allocate a growing collection. No memory magnitude was measured, and it is not an attribution of #19768's main-process growth or #19831's application-scope OOM.
|
||||
@@ -0,0 +1,249 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import { build } from 'esbuild'
|
||||
|
||||
if (process.env.ORCA_BACKGROUND_LAUNCH !== '1') {
|
||||
throw new Error('Run with ORCA_BACKGROUND_LAUNCH=1.')
|
||||
}
|
||||
|
||||
const root = fileURLToPath(new URL('../../../', import.meta.url))
|
||||
const sourcePath = 'src/main/runtime/terminal-wait-tail-window.ts'
|
||||
const absoluteSource = resolve(root, sourcePath)
|
||||
const current = await readFile(absoluteSource, 'utf8')
|
||||
const loop = ' while (lineEnd > 0) {'
|
||||
const end = ' lineEnd = lineStart - 1\n }\n return 0\n}'
|
||||
if (current.split(loop).length !== 2 || current.split(end).length !== 2) {
|
||||
throw new Error('Source changed; review the baseline transform.')
|
||||
}
|
||||
const baseline = current
|
||||
.replace(loop, ' for (;;) {')
|
||||
.replace(end, ' lineEnd = lineStart - 1\n }\n}')
|
||||
const sha256 = (value) => createHash('sha256').update(value).digest('hex')
|
||||
const supportingSources = [
|
||||
'src/main/runtime/terminal-wait-detection.ts',
|
||||
'src/main/runtime/orca-runtime-terminal-projection.ts',
|
||||
'src/main/runtime/terminal-tail-read.ts',
|
||||
'src/main/runtime/terminal-tail-state.ts',
|
||||
'src/main/runtime/terminal-wait-tail-state.ts',
|
||||
'src/main/daemon/headless-emulator.ts',
|
||||
'src/main/runtime/terminal-wait-tail-window.test.ts'
|
||||
]
|
||||
const supportingSourceHashes = Object.fromEntries(
|
||||
await Promise.all(
|
||||
supportingSources.map(async (path) => [path, sha256(await readFile(resolve(root, path)))])
|
||||
)
|
||||
)
|
||||
const scratch = await mkdtemp(join(tmpdir(), 'orca-terminal-wait-blank-'))
|
||||
const require = createRequire(import.meta.url)
|
||||
let runnerId
|
||||
|
||||
try {
|
||||
const runnerPath = join(scratch, 'run-process.cjs')
|
||||
await build({
|
||||
entryPoints: [resolve(root, 'src/shared/child-process/run-process.ts')],
|
||||
outfile: runnerPath,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
logLevel: 'silent'
|
||||
})
|
||||
runnerId = require.resolve(runnerPath)
|
||||
const { runProcess } = require(runnerId)
|
||||
const entry = `
|
||||
import { startOfLastNonBlankLines } from './src/main/runtime/terminal-wait-tail-window';
|
||||
import { detectTerminalWaitBlockedReason, isKnownReadyPromptPreview } from './src/main/runtime/terminal-wait-detection';
|
||||
import { HeadlessEmulator } from './src/main/daemon/headless-emulator';
|
||||
import { projectTerminalVisibleLines, projectTerminalTailLines } from './src/main/runtime/orca-runtime-terminal-projection';
|
||||
import { buildPreview } from './src/main/runtime/terminal-tail-state';
|
||||
import { buildTerminalWaitText } from './src/main/runtime/terminal-wait-tail-state';
|
||||
const input = JSON.parse(process.argv[2]);
|
||||
async function main() {
|
||||
process.stdout.write(JSON.stringify({ phase: 'entered', mode: input.mode }) + '\\n');
|
||||
let value;
|
||||
if (input.mode === 'window') value = startOfLastNonBlankLines(input.text, input.count);
|
||||
if (input.mode === 'blocked') value = detectTerminalWaitBlockedReason(input.text);
|
||||
if (input.mode === 'ready') value = isKnownReadyPromptPreview(input.text);
|
||||
if (input.mode === 'producer-controls') {
|
||||
const emulator = new HeadlessEmulator({ cols: 80, rows: 12, scrollback: 0 });
|
||||
try {
|
||||
await emulator.write('\\r\\nordinary output\\r\\n');
|
||||
const raw = emulator.getVisibleLines();
|
||||
const visible = projectTerminalVisibleLines(emulator).lines;
|
||||
const tail = projectTerminalTailLines(emulator, 12).lines;
|
||||
const longLines = ['prefix', 'x'.repeat(299)];
|
||||
const preview = buildPreview(longLines, '');
|
||||
const waitText = buildTerminalWaitText(longLines, '', preview);
|
||||
value = {
|
||||
rawRowsStartBlank: raw[0] === '',
|
||||
visibleRows: visible,
|
||||
projectedTail: tail,
|
||||
visibleClassification: detectTerminalWaitBlockedReason(visible.join('\\n')),
|
||||
ordinaryTailClassification: detectTerminalWaitBlockedReason(buildTerminalWaitText(raw, '', '')),
|
||||
clippedPreviewStartsNewline: preview.startsWith('\\n'),
|
||||
retainedTailStartsNewline: waitText.startsWith('\\n'),
|
||||
retainedTailClassification: detectTerminalWaitBlockedReason(waitText),
|
||||
emptyTailFallbackStartsNewline: buildTerminalWaitText([], '', preview).startsWith('\\n')
|
||||
};
|
||||
} finally { emulator.dispose(); }
|
||||
}
|
||||
process.stdout.write(JSON.stringify({ phase: 'returned', value }) + '\\n');
|
||||
}
|
||||
main().catch(error => { process.stderr.write(String(error)); process.exitCode = 1; });
|
||||
`
|
||||
const cases = [
|
||||
{ name: 'leading newline only', mode: 'window', text: '\n', count: 12, expected: 0 },
|
||||
{ name: 'leading newline and text', mode: 'window', text: '\ntext', count: 12, expected: 0 },
|
||||
{ name: 'blank screen classification', mode: 'blocked', text: '\n\n', expected: null },
|
||||
{
|
||||
name: 'leading blank trust dialog',
|
||||
mode: 'blocked',
|
||||
text: '\nDo you trust this workspace directory?\n1. Yes\n2. No',
|
||||
expected: 'agent-trust-workspace'
|
||||
},
|
||||
{
|
||||
name: 'leading blank ready header',
|
||||
mode: 'ready',
|
||||
text: '\nOpenAI Codex\nmodel: test\ndirectory: /workspace',
|
||||
expected: true
|
||||
},
|
||||
{
|
||||
name: 'enough nonblank rows',
|
||||
mode: 'window',
|
||||
text: '\nfirst\nsecond',
|
||||
count: 1,
|
||||
expected: 7
|
||||
},
|
||||
{
|
||||
name: 'production producer controls',
|
||||
mode: 'producer-controls',
|
||||
expected: {
|
||||
rawRowsStartBlank: true,
|
||||
visibleRows: ['ordinary output'],
|
||||
projectedTail: ['ordinary output'],
|
||||
visibleClassification: null,
|
||||
ordinaryTailClassification: null,
|
||||
clippedPreviewStartsNewline: true,
|
||||
retainedTailStartsNewline: false,
|
||||
retainedTailClassification: null,
|
||||
emptyTailFallbackStartsNewline: true
|
||||
}
|
||||
}
|
||||
]
|
||||
const results = {}
|
||||
for (const [label, source] of Object.entries({ before: baseline, after: current })) {
|
||||
const childPath = join(scratch, `${label}.cjs`)
|
||||
await build({
|
||||
stdin: { contents: entry, resolveDir: root },
|
||||
outfile: childPath,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
logLevel: 'silent',
|
||||
plugins: [
|
||||
{
|
||||
name: 'terminal-wait-baseline',
|
||||
setup(builder) {
|
||||
builder.onLoad({ filter: /terminal-wait-tail-window\.ts$/ }, (args) =>
|
||||
resolve(args.path) === absoluteSource ? { contents: source, loader: 'ts' } : null
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
results[label] = []
|
||||
for (const input of cases) {
|
||||
let childTerminated = false
|
||||
const started = performance.now()
|
||||
const result = await runProcess({
|
||||
program: process.execPath,
|
||||
args: ['--max-old-space-size=128', childPath, JSON.stringify(input)],
|
||||
env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' },
|
||||
timeoutMs: 2_000,
|
||||
maxOutputBytes: 8192,
|
||||
onChildTerminated: () => {
|
||||
childTerminated = true
|
||||
}
|
||||
})
|
||||
const output = result.stdout
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line))
|
||||
const expectedTimeout = label === 'before' && cases.indexOf(input) < 5
|
||||
const returned = output.find((event) => event.phase === 'returned')
|
||||
if (
|
||||
!childTerminated ||
|
||||
result.timedOut !== expectedTimeout ||
|
||||
!output.some((event) => event.phase === 'entered')
|
||||
) {
|
||||
throw new Error(`Unexpected ${label} result for ${input.name}: ${JSON.stringify(result)}`)
|
||||
}
|
||||
if (
|
||||
!expectedTimeout &&
|
||||
(result.code !== 0 ||
|
||||
!returned ||
|
||||
('expected' in input && !isDeepStrictEqual(returned.value, input.expected)))
|
||||
) {
|
||||
throw new Error(
|
||||
`Unexpected ${label} output for ${input.name}: ${result.stdout} ${result.stderr}`
|
||||
)
|
||||
}
|
||||
results[label].push({
|
||||
name: input.name,
|
||||
timedOut: result.timedOut,
|
||||
childTerminated,
|
||||
code: result.code,
|
||||
signal: result.signal,
|
||||
elapsedMs: Math.round(performance.now() - started),
|
||||
...(returned ? { value: returned.value } : {})
|
||||
})
|
||||
}
|
||||
}
|
||||
const tag = await runProcess({
|
||||
program: 'git',
|
||||
args: ['show', `v1.4.198:${sourcePath}`],
|
||||
cwd: root,
|
||||
maxOutputBytes: 16_384
|
||||
})
|
||||
const provenance = await runProcess({
|
||||
program: 'git',
|
||||
args: ['rev-parse', 'HEAD'],
|
||||
cwd: root,
|
||||
maxOutputBytes: 1024
|
||||
})
|
||||
process.stdout.write(
|
||||
`${JSON.stringify(
|
||||
{
|
||||
node: process.version,
|
||||
platform: process.platform,
|
||||
architecture: process.arch,
|
||||
revision: provenance.stdout.trim(),
|
||||
source: sourcePath,
|
||||
hashes: {
|
||||
before: sha256(baseline),
|
||||
after: sha256(current),
|
||||
reportedVersion: tag.code === 0 ? sha256(tag.stdout) : null
|
||||
},
|
||||
supportingSourceHashes,
|
||||
reportedVersionSourceMatchesBaseline: tag.code === 0 && tag.stdout === baseline,
|
||||
childTimeoutMs: 2000,
|
||||
childHeapLimitMiB: 128,
|
||||
scope:
|
||||
'Helper termination and producer controls; no incident attribution or retained-byte claim.',
|
||||
results
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`
|
||||
)
|
||||
} finally {
|
||||
if (runnerId) {
|
||||
delete require.cache[runnerId]
|
||||
}
|
||||
await rm(scratch, { recursive: true, force: true })
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
{
|
||||
"node": "v26.6.0",
|
||||
"platform": "darwin",
|
||||
"architecture": "arm64",
|
||||
"revision": "9558152a04c499c666192dd08906be8ada09e1dc",
|
||||
"source": "src/main/runtime/terminal-wait-tail-window.ts",
|
||||
"hashes": {
|
||||
"before": "e6bc10e924d45bf4bfbd6fabf4404f160107cf7235b11c4e140cd6c78cff9146",
|
||||
"after": "8df53ea8f92461549d727f251dae7cb4415d4208228ec5f60df9dcf13b863b86",
|
||||
"reportedVersion": "e6bc10e924d45bf4bfbd6fabf4404f160107cf7235b11c4e140cd6c78cff9146"
|
||||
},
|
||||
"supportingSourceHashes": {
|
||||
"src/main/runtime/terminal-wait-detection.ts": "226d09d2d8f7f1d692fb8ba1ed340818e6bd402b74d7bd98d86b1868a09503f0",
|
||||
"src/main/runtime/orca-runtime-terminal-projection.ts": "70b815cf26864719b30b64845c0035093c16b9d85cb4bb8d25c4ae5fa63c3026",
|
||||
"src/main/runtime/terminal-tail-read.ts": "3517bb22b9bf4bdf2f3acee8ceac9ca71221964cfa3f16be1f7b2ad22212b476",
|
||||
"src/main/runtime/terminal-tail-state.ts": "a6f41a683d5f03023e4f6d20d653ebf069b036f82100c908a775a1890c49b2ef",
|
||||
"src/main/runtime/terminal-wait-tail-state.ts": "61acc15fb8b9ce7faa0a2df80a7b6bae09dfed103c15b9c6c35a66d6ad585db1",
|
||||
"src/main/daemon/headless-emulator.ts": "ace96102285c3967cd9544e7696fe73e1ac6df576b476939aa9819f5c298bab6",
|
||||
"src/main/runtime/terminal-wait-tail-window.test.ts": "0faab1ca7b01e43ab555fd7fe2979975b818c22f25eca4f74dea3eb04f02c66b"
|
||||
},
|
||||
"reportedVersionSourceMatchesBaseline": true,
|
||||
"childTimeoutMs": 2000,
|
||||
"childHeapLimitMiB": 128,
|
||||
"scope": "Helper termination and producer controls; no incident attribution or retained-byte claim.",
|
||||
"results": {
|
||||
"before": [
|
||||
{
|
||||
"name": "leading newline only",
|
||||
"timedOut": true,
|
||||
"childTerminated": true,
|
||||
"code": null,
|
||||
"signal": "SIGTERM",
|
||||
"elapsedMs": 2004
|
||||
},
|
||||
{
|
||||
"name": "leading newline and text",
|
||||
"timedOut": true,
|
||||
"childTerminated": true,
|
||||
"code": null,
|
||||
"signal": "SIGTERM",
|
||||
"elapsedMs": 2002
|
||||
},
|
||||
{
|
||||
"name": "blank screen classification",
|
||||
"timedOut": true,
|
||||
"childTerminated": true,
|
||||
"code": null,
|
||||
"signal": "SIGTERM",
|
||||
"elapsedMs": 2002
|
||||
},
|
||||
{
|
||||
"name": "leading blank trust dialog",
|
||||
"timedOut": true,
|
||||
"childTerminated": true,
|
||||
"code": null,
|
||||
"signal": "SIGTERM",
|
||||
"elapsedMs": 2002
|
||||
},
|
||||
{
|
||||
"name": "leading blank ready header",
|
||||
"timedOut": true,
|
||||
"childTerminated": true,
|
||||
"code": null,
|
||||
"signal": "SIGTERM",
|
||||
"elapsedMs": 2002
|
||||
},
|
||||
{
|
||||
"name": "enough nonblank rows",
|
||||
"timedOut": false,
|
||||
"childTerminated": true,
|
||||
"code": 0,
|
||||
"signal": null,
|
||||
"elapsedMs": 32,
|
||||
"value": 7
|
||||
},
|
||||
{
|
||||
"name": "production producer controls",
|
||||
"timedOut": false,
|
||||
"childTerminated": true,
|
||||
"code": 0,
|
||||
"signal": null,
|
||||
"elapsedMs": 36,
|
||||
"value": {
|
||||
"rawRowsStartBlank": true,
|
||||
"visibleRows": ["ordinary output"],
|
||||
"projectedTail": ["ordinary output"],
|
||||
"visibleClassification": null,
|
||||
"ordinaryTailClassification": null,
|
||||
"clippedPreviewStartsNewline": true,
|
||||
"retainedTailStartsNewline": false,
|
||||
"retainedTailClassification": null,
|
||||
"emptyTailFallbackStartsNewline": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"after": [
|
||||
{
|
||||
"name": "leading newline only",
|
||||
"timedOut": false,
|
||||
"childTerminated": true,
|
||||
"code": 0,
|
||||
"signal": null,
|
||||
"elapsedMs": 34,
|
||||
"value": 0
|
||||
},
|
||||
{
|
||||
"name": "leading newline and text",
|
||||
"timedOut": false,
|
||||
"childTerminated": true,
|
||||
"code": 0,
|
||||
"signal": null,
|
||||
"elapsedMs": 33,
|
||||
"value": 0
|
||||
},
|
||||
{
|
||||
"name": "blank screen classification",
|
||||
"timedOut": false,
|
||||
"childTerminated": true,
|
||||
"code": 0,
|
||||
"signal": null,
|
||||
"elapsedMs": 33,
|
||||
"value": null
|
||||
},
|
||||
{
|
||||
"name": "leading blank trust dialog",
|
||||
"timedOut": false,
|
||||
"childTerminated": true,
|
||||
"code": 0,
|
||||
"signal": null,
|
||||
"elapsedMs": 33,
|
||||
"value": "agent-trust-workspace"
|
||||
},
|
||||
{
|
||||
"name": "leading blank ready header",
|
||||
"timedOut": false,
|
||||
"childTerminated": true,
|
||||
"code": 0,
|
||||
"signal": null,
|
||||
"elapsedMs": 33,
|
||||
"value": true
|
||||
},
|
||||
{
|
||||
"name": "enough nonblank rows",
|
||||
"timedOut": false,
|
||||
"childTerminated": true,
|
||||
"code": 0,
|
||||
"signal": null,
|
||||
"elapsedMs": 32,
|
||||
"value": 7
|
||||
},
|
||||
{
|
||||
"name": "production producer controls",
|
||||
"timedOut": false,
|
||||
"childTerminated": true,
|
||||
"code": 0,
|
||||
"signal": null,
|
||||
"elapsedMs": 37,
|
||||
"value": {
|
||||
"rawRowsStartBlank": true,
|
||||
"visibleRows": ["ordinary output"],
|
||||
"projectedTail": ["ordinary output"],
|
||||
"visibleClassification": null,
|
||||
"ordinaryTailClassification": null,
|
||||
"clippedPreviewStartsNewline": true,
|
||||
"retainedTailStartsNewline": false,
|
||||
"retainedTailClassification": null,
|
||||
"emptyTailFallbackStartsNewline": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { build } from 'esbuild'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { runProcess } from '../../shared/child-process/run-process'
|
||||
import { startOfLastNonBlankLines } from './terminal-wait-tail-window'
|
||||
|
||||
let scratch = ''
|
||||
let childPath = ''
|
||||
|
||||
beforeAll(async () => {
|
||||
scratch = await mkdtemp(join(tmpdir(), 'orca-tail-window-'))
|
||||
childPath = join(scratch, 'leading-blank.cjs')
|
||||
await build({
|
||||
stdin: {
|
||||
contents: `
|
||||
import { startOfLastNonBlankLines } from './src/main/runtime/terminal-wait-tail-window';
|
||||
import { detectTerminalWaitBlockedReason, isKnownReadyPromptPreview } from './src/main/runtime/terminal-wait-detection';
|
||||
const tails = ['', '\\n', '\\n\\n', '\\ntext', '\\ntext\\n', '\\n \\t\\ntext\\n\\n'];
|
||||
process.stdout.write(JSON.stringify({
|
||||
offsets: tails.map(value => startOfLastNonBlankLines(value, 12)),
|
||||
blank: detectTerminalWaitBlockedReason('\\n\\n'),
|
||||
ordinary: detectTerminalWaitBlockedReason('\\nordinary output'),
|
||||
blocked: detectTerminalWaitBlockedReason('\\nDo you trust this workspace directory?\\n1. Yes\\n2. No'),
|
||||
ready: isKnownReadyPromptPreview('\\nOpenAI Codex\\nmodel: test\\ndirectory: /workspace')
|
||||
}));
|
||||
`,
|
||||
resolveDir: process.cwd()
|
||||
},
|
||||
outfile: childPath,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
format: 'cjs',
|
||||
logLevel: 'silent'
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
if (scratch) {
|
||||
await rm(scratch, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('terminal wait nonblank tail window', () => {
|
||||
it('terminates on leading blank rows before classifying the remaining screen', async () => {
|
||||
// Isolate the synchronous regression so its timeout cannot block the test worker.
|
||||
const result = await runProcess({
|
||||
program: process.execPath,
|
||||
args: ['--max-old-space-size=64', childPath],
|
||||
env: { ...process.env, ORCA_BACKGROUND_LAUNCH: '1' },
|
||||
timeoutMs: 2_000,
|
||||
maxOutputBytes: 4096
|
||||
})
|
||||
expect(result.timedOut).toBe(false)
|
||||
expect(result.code, result.stderr).toBe(0)
|
||||
expect(JSON.parse(result.stdout)).toEqual({
|
||||
offsets: [0, 0, 0, 0, 0, 0],
|
||||
blank: null,
|
||||
ordinary: null,
|
||||
blocked: 'agent-trust-workspace',
|
||||
ready: true
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ value: 'first\nsecond\nthird', count: 2, expected: 'second\nthird' },
|
||||
{ value: 'first\n\n \t\nsecond\nthird\n', count: 2, expected: 'second\nthird\n' },
|
||||
{ value: '\nfirst\nsecond', count: 1, expected: 'second' },
|
||||
{ value: '\nfirst\nsecond', count: 2, expected: 'first\nsecond' },
|
||||
{ value: 'first\nsecond', count: 3, expected: 'first\nsecond' },
|
||||
{ value: 'first\nsecond\n\n', count: 1, expected: 'second\n\n' },
|
||||
{ value: ' \t\r\nsecond', count: 2, expected: ' \t\r\nsecond' }
|
||||
])('selects the last $count nonblank rows of $value', ({ value, count, expected }) => {
|
||||
expect(value.slice(startOfLastNonBlankLines(value, count))).toBe(expected)
|
||||
})
|
||||
})
|
||||
@@ -23,7 +23,7 @@ export function startOfLastLines(value: string, count: number): number {
|
||||
export function startOfLastNonBlankLines(value: string, count: number): number {
|
||||
let seen = 0
|
||||
let lineEnd = value.length
|
||||
for (;;) {
|
||||
while (lineEnd > 0) {
|
||||
const lineStart = value.lastIndexOf('\n', lineEnd - 1) + 1
|
||||
if (hasNonWhitespaceBetween(value, lineStart, lineEnd)) {
|
||||
seen += 1
|
||||
@@ -36,6 +36,7 @@ export function startOfLastNonBlankLines(value: string, count: number): number {
|
||||
}
|
||||
lineEnd = lineStart - 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function hasNonWhitespaceBetween(value: string, start: number, end: number): boolean {
|
||||
|
||||
Reference in New Issue
Block a user