diff --git a/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/widgets/FormatValueBase.js b/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/widgets/FormatValueBase.js index 35bc4c2664..139d773f60 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/widgets/FormatValueBase.js +++ b/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/widgets/FormatValueBase.js @@ -16,29 +16,59 @@ */ import 'sprintf-js' +// Match any whitespace control character we want to make visible in +// single-line displays. Kept as a module-level constant so the regex is +// compiled once and reused across every formatValueBase call. +const WHITESPACE_CONTROL_REGEX = /[\n\r\t]/ +const WHITESPACE_CONTROL_REGEX_G = /[\n\r\t]/g export default { methods: { - formatValueBase(value, formatString) { + formatValueBase(value, formatString, options) { if (this.isJsonString(value)) { return this.formatJsonString(value) } + const preserveWhitespace = options?.preserveWhitespace === true if (Array.isArray(value)) { - return JSON.stringify(value).replace(/\\n/g, '') + return this.escapeWhitespace(JSON.stringify(value), preserveWhitespace) } if (this.isObject(value)) { - return JSON.stringify(value).replace(/\\n/g, '') + return this.escapeWhitespace(JSON.stringify(value), preserveWhitespace) } if (formatString && value) { if (typeof value === 'bigint') { return this.formatBigInt(value, formatString) } - return sprintf(formatString, value) + return this.escapeWhitespace( + sprintf(formatString, value), + preserveWhitespace, + ) } if (value === null || value === undefined) { return 'null' } + if (typeof value === 'string') { + return this.escapeWhitespace(value, preserveWhitespace) + } return String(value) }, + // Make embedded newlines, carriage returns, and tabs visible in + // single-line displays (e.g. Packet Viewer) by replacing them with their + // escape sequences. Pass preserveWhitespace=true to return the value + // unchanged for multi-line displays such as the TEXTBOX widget. + escapeWhitespace(value, preserveWhitespace) { + if (preserveWhitespace || typeof value !== 'string') { + return value + } + // Cheap test first so we only allocate a new string when needed. + if (!WHITESPACE_CONTROL_REGEX.test(value)) { + return value + } + return value.replace(WHITESPACE_CONTROL_REGEX_G, (m) => { + if (m === '\n') return String.raw`\n` + if (m === '\r') return String.raw`\r` + return String.raw`\t` + }) + }, // sprintf-js doesn't support BigInt values so we handle common // integer format specifiers manually to preserve full precision formatBigInt(value, formatString) { diff --git a/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/widgets/TextboxWidget.vue b/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/widgets/TextboxWidget.vue index a9d02a3153..895246aafe 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/widgets/TextboxWidget.vue +++ b/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/widgets/TextboxWidget.vue @@ -66,6 +66,9 @@ export default { return { width: 200, height: 200, + // Let v-textarea render embedded newlines as actual line breaks + // rather than the escaped "\n" sequence used by single-line displays. + preserveWhitespace: true, } }, computed: { diff --git a/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/widgets/VWidget.js b/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/widgets/VWidget.js index 02b6248ae1..ac1c65f1c6 100644 --- a/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/widgets/VWidget.js +++ b/openc3-cosmos-init/plugins/packages/openc3-vue-common/src/widgets/VWidget.js @@ -43,6 +43,9 @@ export default { emits: ['addItem', 'deleteItem', 'open'], data() { return { + // Subclasses (e.g. TextboxWidget) set this true to let embedded + // newlines / tabs pass through to multi-line displays unchanged. + preserveWhitespace: false, appliedTimeZone: 'local', curValue: null, prevValue: null, @@ -214,7 +217,9 @@ export default { this.appliedTimeZone, ) } - return this.formatValueBase(value, this.formatString) + return this.formatValueBase(value, this.formatString, { + preserveWhitespace: this.preserveWhitespace, + }) } catch (e) { // eslint-disable-next-line no-console console.log( diff --git a/playwright/tests/packet-viewer.p.spec.ts b/playwright/tests/packet-viewer.p.spec.ts index 9019502a50..eca508519c 100644 --- a/playwright/tests/packet-viewer.p.spec.ts +++ b/playwright/tests/packet-viewer.p.spec.ts @@ -150,6 +150,58 @@ test('gets details with right click', async ({ page, utils }) => { ) }) +// Regression for openc3#154: newlines embedded in a string telemetry value +// must be visible in the single-line Packet Viewer cell (escaped as \n) and +// not silently truncate everything after the first newline. +test('displays embedded newlines in string telemetry values', async ({ + page, + utils, +}) => { + // 1) Override INST HEALTH_STATUS ASCIICMD with a multi-line string. We do + // this via Script Runner because the cmd-tlm API does not expose + // override_tlm directly over HTTP. + await page.goto('/tools/scriptrunner') + await expect(page.locator('.v-app-bar')).toContainText('Script Runner') + await page.locator('[data-test=script-runner-file]').click() + await page.locator('text=New File').click() + await expect(page.locator('textarea')).toHaveText('') + await page + .locator('textarea') + .fill( + String.raw`override_tlm("INST HEALTH_STATUS ASCIICMD = \"line1\nline2\nline3\"")`, + ) + await page.locator('[data-test=start-button]').click() + await expect(page.locator('[data-test=state] input')).toHaveValue( + 'completed', + { timeout: 30000 }, + ) + + // 2) Verify the Packet Viewer renders the embedded newlines as a literal + // `\n` sequence rather than chopping at the first newline. + await page.goto('/tools/packetviewer/INST/HEALTH_STATUS/') + await expect(page.locator('.v-app-bar')).toContainText('Packet Viewer') + await expect + .poll( + async () => + await page.inputValue('tr:has(td div:text-is("ASCIICMD")) input'), + { timeout: 10000 }, + ) + .toBe(String.raw`line1\nline2\nline3`) + + // 3) Clear the override so we don't leak state into other tests. + await page.goto('/tools/scriptrunner') + await page.locator('[data-test=script-runner-file]').click() + await page.locator('text=New File').click() + await page + .locator('textarea') + .fill(`normalize_tlm("INST HEALTH_STATUS ASCIICMD")`) + await page.locator('[data-test=start-button]').click() + await expect(page.locator('[data-test=state] input')).toHaveValue( + 'completed', + { timeout: 30000 }, + ) +}) + test('stops posting to the api after closing', async ({ page, utils }) => { await page.goto('/tools/packetviewer/INST/ADCS/') let requestCount = 0