diff --git a/packages/apps-engine/tests/server/accessors/SchedulerModify.spec.ts b/packages/apps-engine/tests/server/accessors/SchedulerModify.spec.ts new file mode 100644 index 0000000000000..35926f55a92a9 --- /dev/null +++ b/packages/apps-engine/tests/server/accessors/SchedulerModify.spec.ts @@ -0,0 +1,137 @@ +import { AsyncTest, Expect, SetupFixture } from 'alsatian'; + +import type { IOnetimeSchedule, IRecurringSchedule } from '../../../src/definition/scheduler'; +import { SchedulerModify } from '../../../src/server/accessors'; +import type { SchedulerBridge } from '../../../src/server/bridges'; + +export class SchedulerModifyAccessorTestFixture { + private mockSchedulerBridge: SchedulerBridge; + + private scheduleOnceCalls: Array<{ job: IOnetimeSchedule; appId: string }>; + + private scheduleRecurringCalls: Array<{ job: IRecurringSchedule; appId: string }>; + + private cancelJobCalls: Array<{ jobId: string; appId: string }>; + + private cancelAllJobsCalls: string[]; + + @SetupFixture + public setupFixture(): void { + this.scheduleOnceCalls = []; + this.scheduleRecurringCalls = []; + this.cancelJobCalls = []; + this.cancelAllJobsCalls = []; + + const { scheduleOnceCalls, scheduleRecurringCalls, cancelJobCalls, cancelAllJobsCalls } = this; + + this.mockSchedulerBridge = { + doScheduleOnce(job: IOnetimeSchedule, appId: string): Promise { + scheduleOnceCalls.push({ job, appId }); + return Promise.resolve(); + }, + doScheduleRecurring(job: IRecurringSchedule, appId: string): Promise { + scheduleRecurringCalls.push({ job, appId }); + return Promise.resolve(); + }, + doCancelJob(jobId: string, appId: string): Promise { + cancelJobCalls.push({ jobId, appId }); + return Promise.resolve(); + }, + doCancelAllJobs(appId: string): Promise { + cancelAllJobsCalls.push(appId); + return Promise.resolve(); + }, + } as SchedulerBridge; + } + + @AsyncTest() + public async scheduleOnceAppendsAppIdWhenMissing(): Promise { + const scheduler = new SchedulerModify(this.mockSchedulerBridge, 'test-app'); + const job: IOnetimeSchedule = { + id: 'job-1', + when: new Date('2026-03-24T12:00:00.000Z'), + data: { pollId: '123' }, + }; + + await Expect(() => scheduler.scheduleOnce(job)).not.toThrowAsync(); + + Expect(this.scheduleOnceCalls.length).toBe(1); + Expect(this.scheduleOnceCalls[0].appId).toBe('test-app'); + Expect(this.scheduleOnceCalls[0].job).toEqual({ + ...job, + id: 'job-1_test-app', + }); + Expect(job.id).toBe('job-1'); + } + + @AsyncTest() + public async scheduleOnceDoesNotAppendAppIdTwice(): Promise { + const scheduler = new SchedulerModify(this.mockSchedulerBridge, 'test-app'); + const job: IOnetimeSchedule = { + id: 'job-1_test-app', + when: 'in 5 minutes', + }; + + await scheduler.scheduleOnce(job); + + Expect(this.scheduleOnceCalls.length).toBe(1); + Expect(this.scheduleOnceCalls[0].job.id).toBe('job-1_test-app'); + } + + @AsyncTest() + public async scheduleRecurringAppendsAppIdWhenMissing(): Promise { + const scheduler = new SchedulerModify(this.mockSchedulerBridge, 'test-app'); + const job: IRecurringSchedule = { + id: 'recurring-1', + interval: '5 minutes', + skipImmediate: true, + data: { roomId: 'abc' }, + }; + + await scheduler.scheduleRecurring(job); + + Expect(this.scheduleRecurringCalls.length).toBe(1); + Expect(this.scheduleRecurringCalls[0].appId).toBe('test-app'); + Expect(this.scheduleRecurringCalls[0].job).toEqual({ + ...job, + id: 'recurring-1_test-app', + }); + Expect(job.id).toBe('recurring-1'); + } + + @AsyncTest() + public async cancelJobAppendsAppIdWhenMissing(): Promise { + const scheduler = new SchedulerModify(this.mockSchedulerBridge, 'test-app'); + + await scheduler.cancelJob('cleanup-job'); + + Expect(this.cancelJobCalls.length).toBe(1); + Expect(this.cancelJobCalls[0]).toEqual({ + jobId: 'cleanup-job_test-app', + appId: 'test-app', + }); + } + + @AsyncTest() + public async cancelJobDoesNotAppendAppIdTwice(): Promise { + const scheduler = new SchedulerModify(this.mockSchedulerBridge, 'test-app'); + + await scheduler.cancelJob('cleanup-job_test-app'); + + Expect(this.cancelJobCalls.length).toBe(1); + Expect(this.cancelJobCalls[0]).toEqual({ + jobId: 'cleanup-job_test-app', + appId: 'test-app', + }); + } + + @AsyncTest() + public async cancelAllJobsUsesOnlyAppId(): Promise { + const scheduler = new SchedulerModify(this.mockSchedulerBridge, 'test-app'); + + await scheduler.cancelAllJobs(); + + Expect(this.cancelAllJobsCalls.length).toBe(1); + Expect(this.cancelAllJobsCalls[0]).toBe('test-app'); + } +} diff --git a/packages/message-parser/package.json b/packages/message-parser/package.json index 4d8c3ab673e16..9a9e7bcc563cf 100644 --- a/packages/message-parser/package.json +++ b/packages/message-parser/package.json @@ -52,6 +52,7 @@ "@types/jest": "~30.0.0", "@types/node": "~22.16.5", "eslint": "~9.39.3", + "fast-check": "^4.6.0", "jest": "~30.2.0", "npm-run-all": "^4.1.5", "peggy": "4.1.1", diff --git a/packages/message-parser/src/grammar.pegjs b/packages/message-parser/src/grammar.pegjs index 57084728715d5..2b24ec405ccb3 100644 --- a/packages/message-parser/src/grammar.pegjs +++ b/packages/message-parser/src/grammar.pegjs @@ -263,7 +263,16 @@ InlineEmoji = & { return !skipInlineEmoji; } emo:Emoji { return emo; } InlineEmoticon = & { return !skipInlineEmoji; } emo:Emoticon & (EmoticonNeighbor / InlineItemPattern) { skipInlineEmoji = false; return emo; } +EscapedTimestampRules + = "\\" "" { + return plain(``); + } + / "\\" "" { + return plain(``); + } + InlineItemPattern = Whitespace + / EscapedTimestampRules / TimestampRules / MaybeReferences / AutolinkedPhone @@ -568,7 +577,7 @@ BoldEmoticon = & { return !skipBoldEmoji; } emo:Emoticon & (EmoticonNeighbor / B /* Strike */ Strikethrough = [\x7E] [\x7E] @StrikethroughContent [\x7E] [\x7E] / [\x7E] @StrikethroughContent [\x7E] -StrikethroughContent = text:(TimestampRules / Whitespace / InlineCode / MaybeReferences / UserMention / ChannelMention / MaybeItalic / MaybeBold / Emoji / Emoticon / AnyStrike / Line)+ { +StrikethroughContent = text:(EscapedTimestampRules / TimestampRules / Whitespace / InlineCode / MaybeReferences / UserMention / ChannelMention / MaybeItalic / MaybeBold / Emoji / Emoticon / AnyStrike / Line)+ { return strike(reducePlainTexts(text)); } diff --git a/packages/message-parser/tests/escaped.test.ts b/packages/message-parser/tests/escaped.test.ts index 78a759f7b2c6c..5266d1e86025c 100644 --- a/packages/message-parser/tests/escaped.test.ts +++ b/packages/message-parser/tests/escaped.test.ts @@ -15,6 +15,7 @@ test.each([ ['\\# not a heading', [paragraph([plain('# not a heading')])]], ['\\[foo]: /url "not a reference"', [paragraph([plain('\\[foo]: /url "not a reference"')])]], ['\\ö not a character entity', [paragraph([plain('\\ö not a character entity')])]], + ['\\', [paragraph([plain('')])]], ])('parses %p', (input, output) => { expect(parse(input)).toMatchObject(output); }); diff --git a/packages/message-parser/tests/fuzz.test.ts b/packages/message-parser/tests/fuzz.test.ts new file mode 100644 index 0000000000000..a3bf00a3158c3 --- /dev/null +++ b/packages/message-parser/tests/fuzz.test.ts @@ -0,0 +1,60 @@ +import fc from 'fast-check'; + +import { parse } from '../src'; + +const plainTextArbitrary = fc + .stringMatching(/[a-zA-Z0-9 ]{1,20}/) + .filter((value) => value.length > 0); + +const timestampArbitrary = fc + .integer({ min: 1_000_000_000, max: 2_000_000_000 }) + .map((value) => ``); + +const relativeTimestampArbitrary = fc + .integer({ min: 1_000_000_000, max: 2_000_000_000 }) + .map((value) => ``); + +const mentionArbitrary = fc + .stringMatching(/[a-z0-9._-]{1,12}/) + .map((value) => `@${value}`); + +const boldArbitrary = plainTextArbitrary.map((value) => `*${value}*`); + +const strikeArbitrary = fc.oneof(timestampArbitrary, relativeTimestampArbitrary, plainTextArbitrary).map((value) => `~${value}~`); + +const inlineCodeArbitrary = plainTextArbitrary.map((value) => `\`${value}\``); + +const parserInputArbitrary = fc + .array( + fc.oneof( + plainTextArbitrary, + timestampArbitrary, + relativeTimestampArbitrary, + mentionArbitrary, + boldArbitrary, + strikeArbitrary, + inlineCodeArbitrary, + ), + { minLength: 1, maxLength: 8 }, + ) + .map((parts) => parts.join(' ')); + +describe('parser fuzz tests', () => { + test('parses valid parser-like inputs without throwing', () => { + fc.assert( + fc.property(parserInputArbitrary, (input) => { + expect(() => parse(input)).not.toThrow(); + }), + { numRuns: 300 }, + ); + }); + + test('is deterministic for valid parser-like inputs', () => { + fc.assert( + fc.property(parserInputArbitrary, (input) => { + expect(parse(input)).toEqual(parse(input)); + }), + { numRuns: 300 }, + ); + }); +}); \ No newline at end of file diff --git a/packages/message-parser/tests/timestamp.test.ts b/packages/message-parser/tests/timestamp.test.ts index 6652c280ee466..3cd95cef73d38 100644 --- a/packages/message-parser/tests/timestamp.test.ts +++ b/packages/message-parser/tests/timestamp.test.ts @@ -34,6 +34,7 @@ test.each([ test.each([ ['~~', [paragraph([strike([timestampNode('1708551317')])])]], + ['~~', [paragraph([strike([timestampNode('1708551317', 'R')])])]], ['**', [paragraph([bold([plain('')])])]], ])('parses %p', (input, output) => { expect(parse(input)).toMatchObject(output); diff --git a/yarn.lock b/yarn.lock index f49420cef9f99..f3602e6cce97f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9713,6 +9713,7 @@ __metadata: "@types/jest": "npm:~30.0.0" "@types/node": "npm:~22.16.5" eslint: "npm:~9.39.3" + fast-check: "npm:^4.6.0" jest: "npm:~30.2.0" npm-run-all: "npm:^4.1.5" peggy: "npm:4.1.1" @@ -22511,6 +22512,15 @@ __metadata: languageName: node linkType: hard +"fast-check@npm:^4.6.0": + version: 4.6.0 + resolution: "fast-check@npm:4.6.0" + dependencies: + pure-rand: "npm:^8.0.0" + checksum: 10/b53b88ebf6247ea0eaa7f3f6d8923035e0cfd6f78bc8c393754cc1ccbdb591756dfbff02058a136b472e011279594fa72c05a30378177d6055a08ad7ed1b823e + languageName: node + linkType: hard + "fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3": version: 3.1.3 resolution: "fast-deep-equal@npm:3.1.3" @@ -32090,6 +32100,13 @@ __metadata: languageName: node linkType: hard +"pure-rand@npm:^8.0.0": + version: 8.0.0 + resolution: "pure-rand@npm:8.0.0" + checksum: 10/a5c84320f7337a6984e45cba969c804797535bb03b850897f0ee367d2d531feb489df220d475acc1dcf925c0832ed19a11c2928a94e9197ece5ad003368cb9c7 + languageName: node + linkType: hard + "pvtsutils@npm:^1.3.6": version: 1.3.6 resolution: "pvtsutils@npm:1.3.6"