Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2023-03-06 21:08:12 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2023-03-06 21:08:12 +0300
commite22c3819ad2321a0cf825877fe3b60e41268c5b3 (patch)
treefcd143b30bdd7b42d439cd0b2fc5c6c4268d8d97 /spec/frontend/streaming
parent49b16b71778148e9f9c579bf7bf69853c780c827 (diff)
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'spec/frontend/streaming')
-rw-r--r--spec/frontend/streaming/chunk_writer_spec.js214
-rw-r--r--spec/frontend/streaming/handle_streamed_anchor_link_spec.js132
-rw-r--r--spec/frontend/streaming/html_stream_spec.js46
-rw-r--r--spec/frontend/streaming/rate_limit_stream_requests_spec.js155
-rw-r--r--spec/frontend/streaming/render_balancer_spec.js69
-rw-r--r--spec/frontend/streaming/render_html_streams_spec.js96
6 files changed, 712 insertions, 0 deletions
diff --git a/spec/frontend/streaming/chunk_writer_spec.js b/spec/frontend/streaming/chunk_writer_spec.js
new file mode 100644
index 00000000000..2aadb332838
--- /dev/null
+++ b/spec/frontend/streaming/chunk_writer_spec.js
@@ -0,0 +1,214 @@
+import { ChunkWriter } from '~/streaming/chunk_writer';
+import { RenderBalancer } from '~/streaming/render_balancer';
+
+jest.mock('~/streaming/render_balancer');
+
+describe('ChunkWriter', () => {
+ let accumulator = '';
+ let write;
+ let close;
+ let abort;
+ let config;
+ let render;
+
+ const createChunk = (text) => {
+ const encoder = new TextEncoder();
+ return encoder.encode(text);
+ };
+
+ const createHtmlStream = () => {
+ write = jest.fn((part) => {
+ accumulator += part;
+ });
+ close = jest.fn();
+ abort = jest.fn();
+ return {
+ write,
+ close,
+ abort,
+ };
+ };
+
+ const createWriter = () => {
+ return new ChunkWriter(createHtmlStream(), config);
+ };
+
+ const pushChunks = (...chunks) => {
+ const writer = createWriter();
+ chunks.forEach((chunk) => {
+ writer.write(createChunk(chunk));
+ });
+ writer.close();
+ };
+
+ afterAll(() => {
+ global.JEST_DEBOUNCE_THROTTLE_TIMEOUT = undefined;
+ });
+
+ beforeEach(() => {
+ global.JEST_DEBOUNCE_THROTTLE_TIMEOUT = 100;
+ accumulator = '';
+ config = undefined;
+ render = jest.fn((cb) => {
+ while (cb()) {
+ // render until 'false'
+ }
+ });
+ RenderBalancer.mockImplementation(() => ({ render }));
+ });
+
+ describe('when chunk length must be "1"', () => {
+ beforeEach(() => {
+ config = { minChunkSize: 1, maxChunkSize: 1 };
+ });
+
+ it('splits big chunks into smaller ones', () => {
+ const text = 'foobar';
+ pushChunks(text);
+ expect(accumulator).toBe(text);
+ expect(write).toHaveBeenCalledTimes(text.length);
+ });
+
+ it('handles small emoji chunks', () => {
+ const text = 'fooπŸ‘€barπŸ‘¨β€πŸ‘©β€πŸ‘§bazπŸ‘§πŸ‘§πŸ»πŸ‘§πŸΌπŸ‘§πŸ½πŸ‘§πŸΎπŸ‘§πŸΏ';
+ pushChunks(text);
+ expect(accumulator).toBe(text);
+ expect(write).toHaveBeenCalledTimes(createChunk(text).length);
+ });
+ });
+
+ describe('when chunk length must not be lower than "5" and exceed "10"', () => {
+ beforeEach(() => {
+ config = { minChunkSize: 5, maxChunkSize: 10 };
+ });
+
+ it('joins small chunks', () => {
+ const text = '12345';
+ pushChunks(...text.split(''));
+ expect(accumulator).toBe(text);
+ expect(write).toHaveBeenCalledTimes(1);
+ expect(close).toHaveBeenCalledTimes(1);
+ });
+
+ it('handles overflow with small chunks', () => {
+ const text = '123456789';
+ pushChunks(...text.split(''));
+ expect(accumulator).toBe(text);
+ expect(write).toHaveBeenCalledTimes(2);
+ expect(close).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls flush on small chunks', () => {
+ global.JEST_DEBOUNCE_THROTTLE_TIMEOUT = undefined;
+ const flushAccumulator = jest.spyOn(ChunkWriter.prototype, 'flushAccumulator');
+ const text = '1';
+ pushChunks(text);
+ expect(accumulator).toBe(text);
+ expect(flushAccumulator).toHaveBeenCalledTimes(1);
+ });
+
+ it('calls flush on large chunks', () => {
+ const flushAccumulator = jest.spyOn(ChunkWriter.prototype, 'flushAccumulator');
+ const text = '1234567890123';
+ const writer = createWriter();
+ writer.write(createChunk(text));
+ jest.runAllTimers();
+ expect(accumulator).toBe(text);
+ expect(flushAccumulator).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('chunk balancing', () => {
+ let increase;
+ let decrease;
+ let renderOnce;
+
+ beforeEach(() => {
+ render = jest.fn((cb) => {
+ let next = true;
+ renderOnce = () => {
+ if (!next) return;
+ next = cb();
+ };
+ });
+ RenderBalancer.mockImplementation(({ increase: inc, decrease: dec }) => {
+ increase = jest.fn(inc);
+ decrease = jest.fn(dec);
+ return {
+ render,
+ };
+ });
+ });
+
+ describe('when frame time exceeds low limit', () => {
+ beforeEach(() => {
+ config = {
+ minChunkSize: 1,
+ maxChunkSize: 5,
+ balanceRate: 10,
+ };
+ });
+
+ it('increases chunk size', () => {
+ const text = '111222223';
+ const writer = createWriter();
+ const chunk = createChunk(text);
+
+ writer.write(chunk);
+
+ renderOnce();
+ increase();
+ renderOnce();
+ renderOnce();
+
+ writer.close();
+
+ expect(accumulator).toBe(text);
+ expect(write.mock.calls).toMatchObject([['111'], ['22222'], ['3']]);
+ expect(close).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('when frame time exceeds high limit', () => {
+ beforeEach(() => {
+ config = {
+ minChunkSize: 1,
+ maxChunkSize: 10,
+ balanceRate: 2,
+ };
+ });
+
+ it('decreases chunk size', () => {
+ const text = '1111112223345';
+ const writer = createWriter();
+ const chunk = createChunk(text);
+
+ writer.write(chunk);
+
+ renderOnce();
+ decrease();
+
+ renderOnce();
+ decrease();
+
+ renderOnce();
+ decrease();
+
+ renderOnce();
+ renderOnce();
+
+ writer.close();
+
+ expect(accumulator).toBe(text);
+ expect(write.mock.calls).toMatchObject([['111111'], ['222'], ['33'], ['4'], ['5']]);
+ expect(close).toHaveBeenCalledTimes(1);
+ });
+ });
+ });
+
+ it('calls abort on htmlStream', () => {
+ const writer = createWriter();
+ writer.abort();
+ expect(abort).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/spec/frontend/streaming/handle_streamed_anchor_link_spec.js b/spec/frontend/streaming/handle_streamed_anchor_link_spec.js
new file mode 100644
index 00000000000..ef17957b2fc
--- /dev/null
+++ b/spec/frontend/streaming/handle_streamed_anchor_link_spec.js
@@ -0,0 +1,132 @@
+import { resetHTMLFixture, setHTMLFixture } from 'helpers/fixtures';
+import waitForPromises from 'helpers/wait_for_promises';
+import { handleStreamedAnchorLink } from '~/streaming/handle_streamed_anchor_link';
+import { scrollToElement } from '~/lib/utils/common_utils';
+import LineHighlighter from '~/blob/line_highlighter';
+import { TEST_HOST } from 'spec/test_constants';
+
+jest.mock('~/lib/utils/common_utils');
+jest.mock('~/blob/line_highlighter');
+
+describe('handleStreamedAnchorLink', () => {
+ const ANCHOR_START = 'L100';
+ const ANCHOR_END = '300';
+ const findRoot = () => document.querySelector('#root');
+
+ afterEach(() => {
+ resetHTMLFixture();
+ });
+
+ describe('when single line anchor is given', () => {
+ beforeEach(() => {
+ delete window.location;
+ window.location = new URL(`${TEST_HOST}#${ANCHOR_START}`);
+ });
+
+ describe('when element is present', () => {
+ beforeEach(() => {
+ setHTMLFixture(`<div id="root"><div id="${ANCHOR_START}"></div></div>`);
+ handleStreamedAnchorLink(findRoot());
+ });
+
+ it('does nothing', async () => {
+ await waitForPromises();
+ expect(scrollToElement).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when element is streamed', () => {
+ let stop;
+ const insertElement = () => {
+ findRoot().insertAdjacentHTML('afterbegin', `<div id="${ANCHOR_START}"></div>`);
+ };
+
+ beforeEach(() => {
+ setHTMLFixture('<div id="root"></div>');
+ stop = handleStreamedAnchorLink(findRoot());
+ });
+
+ afterEach(() => {
+ stop = undefined;
+ });
+
+ it('scrolls to the anchor when inserted', async () => {
+ insertElement();
+ await waitForPromises();
+ expect(scrollToElement).toHaveBeenCalledTimes(1);
+ expect(LineHighlighter).toHaveBeenCalledTimes(1);
+ });
+
+ it("doesn't scroll to the anchor when destroyed", async () => {
+ stop();
+ insertElement();
+ await waitForPromises();
+ expect(scrollToElement).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('when line range anchor is given', () => {
+ beforeEach(() => {
+ delete window.location;
+ window.location = new URL(`${TEST_HOST}#${ANCHOR_START}-${ANCHOR_END}`);
+ });
+
+ describe('when last element is present', () => {
+ beforeEach(() => {
+ setHTMLFixture(`<div id="root"><div id="L${ANCHOR_END}"></div></div>`);
+ handleStreamedAnchorLink(findRoot());
+ });
+
+ it('does nothing', async () => {
+ await waitForPromises();
+ expect(scrollToElement).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('when last element is streamed', () => {
+ let stop;
+ const insertElement = () => {
+ findRoot().insertAdjacentHTML(
+ 'afterbegin',
+ `<div id="${ANCHOR_START}"></div><div id="L${ANCHOR_END}"></div>`,
+ );
+ };
+
+ beforeEach(() => {
+ setHTMLFixture('<div id="root"></div>');
+ stop = handleStreamedAnchorLink(findRoot());
+ });
+
+ afterEach(() => {
+ stop = undefined;
+ });
+
+ it('scrolls to the anchor when inserted', async () => {
+ insertElement();
+ await waitForPromises();
+ expect(scrollToElement).toHaveBeenCalledTimes(1);
+ expect(LineHighlighter).toHaveBeenCalledTimes(1);
+ });
+
+ it("doesn't scroll to the anchor when destroyed", async () => {
+ stop();
+ insertElement();
+ await waitForPromises();
+ expect(scrollToElement).not.toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('when anchor is not given', () => {
+ beforeEach(() => {
+ setHTMLFixture(`<div id="root"></div>`);
+ handleStreamedAnchorLink(findRoot());
+ });
+
+ it('does nothing', async () => {
+ await waitForPromises();
+ expect(scrollToElement).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/streaming/html_stream_spec.js b/spec/frontend/streaming/html_stream_spec.js
new file mode 100644
index 00000000000..115a9ddc803
--- /dev/null
+++ b/spec/frontend/streaming/html_stream_spec.js
@@ -0,0 +1,46 @@
+import { HtmlStream } from '~/streaming/html_stream';
+import { ChunkWriter } from '~/streaming/chunk_writer';
+
+jest.mock('~/streaming/chunk_writer');
+
+describe('HtmlStream', () => {
+ let write;
+ let close;
+ let streamingElement;
+
+ beforeEach(() => {
+ write = jest.fn();
+ close = jest.fn();
+ jest.spyOn(Document.prototype, 'write').mockImplementation(write);
+ jest.spyOn(Document.prototype, 'close').mockImplementation(close);
+ jest.spyOn(Document.prototype, 'querySelector').mockImplementation(() => {
+ streamingElement = document.createElement('div');
+ return streamingElement;
+ });
+ });
+
+ it('attaches to original document', () => {
+ // eslint-disable-next-line no-new
+ new HtmlStream(document.body);
+ expect(document.body.contains(streamingElement)).toBe(true);
+ });
+
+ it('can write to a document', () => {
+ const htmlStream = new HtmlStream(document.body);
+ htmlStream.write('foo');
+ htmlStream.close();
+ expect(write.mock.calls).toEqual([['<streaming-element>'], ['foo'], ['</streaming-element>']]);
+ expect(close).toHaveBeenCalledTimes(1);
+ });
+
+ it('returns chunked writer', () => {
+ const htmlStream = new HtmlStream(document.body).withChunkWriter();
+ expect(htmlStream).toBeInstanceOf(ChunkWriter);
+ });
+
+ it('closes on abort', () => {
+ const htmlStream = new HtmlStream(document.body);
+ htmlStream.abort();
+ expect(close).toHaveBeenCalled();
+ });
+});
diff --git a/spec/frontend/streaming/rate_limit_stream_requests_spec.js b/spec/frontend/streaming/rate_limit_stream_requests_spec.js
new file mode 100644
index 00000000000..02e3cf93014
--- /dev/null
+++ b/spec/frontend/streaming/rate_limit_stream_requests_spec.js
@@ -0,0 +1,155 @@
+import waitForPromises from 'helpers/wait_for_promises';
+import { rateLimitStreamRequests } from '~/streaming/rate_limit_stream_requests';
+
+describe('rateLimitStreamRequests', () => {
+ const encoder = new TextEncoder('utf-8');
+ const createStreamResponse = (content = 'foo') =>
+ new ReadableStream({
+ pull(controller) {
+ controller.enqueue(encoder.encode(content));
+ controller.close();
+ },
+ });
+
+ const createFactory = (content) => {
+ return jest.fn(() => {
+ return Promise.resolve(createStreamResponse(content));
+ });
+ };
+
+ it('does nothing for zero total requests', () => {
+ const factory = jest.fn();
+ const requests = rateLimitStreamRequests({
+ factory,
+ total: 0,
+ });
+ expect(factory).toHaveBeenCalledTimes(0);
+ expect(requests.length).toBe(0);
+ });
+
+ it('does not exceed total requests', () => {
+ const factory = createFactory();
+ const requests = rateLimitStreamRequests({
+ factory,
+ immediateCount: 100,
+ maxConcurrentRequests: 100,
+ total: 2,
+ });
+ expect(factory).toHaveBeenCalledTimes(2);
+ expect(requests.length).toBe(2);
+ });
+
+ it('creates immediate requests', () => {
+ const factory = createFactory();
+ const requests = rateLimitStreamRequests({
+ factory,
+ maxConcurrentRequests: 2,
+ total: 2,
+ });
+ expect(factory).toHaveBeenCalledTimes(2);
+ expect(requests.length).toBe(2);
+ });
+
+ it('returns correct values', async () => {
+ const fixture = 'foobar';
+ const factory = createFactory(fixture);
+ const requests = rateLimitStreamRequests({
+ factory,
+ maxConcurrentRequests: 2,
+ total: 2,
+ });
+
+ const decoder = new TextDecoder('utf-8');
+ let result = '';
+ for await (const stream of requests) {
+ await stream.pipeTo(
+ new WritableStream({
+ // eslint-disable-next-line no-loop-func
+ write(content) {
+ result += decoder.decode(content);
+ },
+ }),
+ );
+ }
+
+ expect(result).toBe(fixture + fixture);
+ });
+
+ it('delays rate limited requests', async () => {
+ const factory = createFactory();
+ const requests = rateLimitStreamRequests({
+ factory,
+ maxConcurrentRequests: 2,
+ total: 3,
+ });
+ expect(factory).toHaveBeenCalledTimes(2);
+ expect(requests.length).toBe(3);
+
+ await waitForPromises();
+
+ expect(factory).toHaveBeenCalledTimes(3);
+ });
+
+ it('runs next request after previous has been fulfilled', async () => {
+ let res;
+ const factory = jest
+ .fn()
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ res = resolve;
+ }),
+ )
+ .mockImplementationOnce(() => Promise.resolve(createStreamResponse()));
+ const requests = rateLimitStreamRequests({
+ factory,
+ maxConcurrentRequests: 1,
+ total: 2,
+ });
+ expect(factory).toHaveBeenCalledTimes(1);
+ expect(requests.length).toBe(2);
+
+ await waitForPromises();
+
+ expect(factory).toHaveBeenCalledTimes(1);
+
+ res(createStreamResponse());
+
+ await waitForPromises();
+
+ expect(factory).toHaveBeenCalledTimes(2);
+ });
+
+ it('uses timer to schedule next request', async () => {
+ let res;
+ const factory = jest
+ .fn()
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ res = resolve;
+ }),
+ )
+ .mockImplementationOnce(() => Promise.resolve(createStreamResponse()));
+ const requests = rateLimitStreamRequests({
+ factory,
+ immediateCount: 1,
+ maxConcurrentRequests: 2,
+ total: 2,
+ timeout: 9999,
+ });
+ expect(factory).toHaveBeenCalledTimes(1);
+ expect(requests.length).toBe(2);
+
+ await waitForPromises();
+
+ expect(factory).toHaveBeenCalledTimes(1);
+
+ jest.runAllTimers();
+
+ await waitForPromises();
+
+ expect(factory).toHaveBeenCalledTimes(2);
+ res(createStreamResponse());
+ });
+});
diff --git a/spec/frontend/streaming/render_balancer_spec.js b/spec/frontend/streaming/render_balancer_spec.js
new file mode 100644
index 00000000000..dae0c98d678
--- /dev/null
+++ b/spec/frontend/streaming/render_balancer_spec.js
@@ -0,0 +1,69 @@
+import { RenderBalancer } from '~/streaming/render_balancer';
+
+const HIGH_FRAME_TIME = 100;
+const LOW_FRAME_TIME = 10;
+
+describe('renderBalancer', () => {
+ let frameTime = 0;
+ let frameTimeDelta = 0;
+ let decrease;
+ let increase;
+
+ const createBalancer = () => {
+ decrease = jest.fn();
+ increase = jest.fn();
+ return new RenderBalancer({
+ highFrameTime: HIGH_FRAME_TIME,
+ lowFrameTime: LOW_FRAME_TIME,
+ increase,
+ decrease,
+ });
+ };
+
+ const renderTimes = (times) => {
+ const balancer = createBalancer();
+ return new Promise((resolve) => {
+ let counter = 0;
+ balancer.render(() => {
+ if (counter === times) {
+ resolve(counter);
+ return false;
+ }
+ counter += 1;
+ return true;
+ });
+ });
+ };
+
+ beforeEach(() => {
+ jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
+ frameTime += frameTimeDelta;
+ cb(frameTime);
+ });
+ });
+
+ afterEach(() => {
+ window.requestAnimationFrame.mockRestore();
+ frameTime = 0;
+ frameTimeDelta = 0;
+ });
+
+ it('renders in a loop', async () => {
+ const count = await renderTimes(5);
+ expect(count).toBe(5);
+ });
+
+ it('calls decrease', async () => {
+ frameTimeDelta = 200;
+ await renderTimes(5);
+ expect(decrease).toHaveBeenCalled();
+ expect(increase).not.toHaveBeenCalled();
+ });
+
+ it('calls increase', async () => {
+ frameTimeDelta = 1;
+ await renderTimes(5);
+ expect(increase).toHaveBeenCalled();
+ expect(decrease).not.toHaveBeenCalled();
+ });
+});
diff --git a/spec/frontend/streaming/render_html_streams_spec.js b/spec/frontend/streaming/render_html_streams_spec.js
new file mode 100644
index 00000000000..55cef0ea469
--- /dev/null
+++ b/spec/frontend/streaming/render_html_streams_spec.js
@@ -0,0 +1,96 @@
+import { ReadableStream } from 'node:stream/web';
+import { renderHtmlStreams } from '~/streaming/render_html_streams';
+import { HtmlStream } from '~/streaming/html_stream';
+import waitForPromises from 'helpers/wait_for_promises';
+
+jest.mock('~/streaming/html_stream');
+jest.mock('~/streaming/constants', () => {
+ return {
+ HIGH_FRAME_TIME: 0,
+ LOW_FRAME_TIME: 0,
+ MAX_CHUNK_SIZE: 1,
+ MIN_CHUNK_SIZE: 1,
+ };
+});
+
+const firstStreamContent = 'foobar';
+const secondStreamContent = 'bazqux';
+
+describe('renderHtmlStreams', () => {
+ let htmlWriter;
+ const encoder = new TextEncoder();
+ const createSingleChunkStream = (chunk) => {
+ const encoded = encoder.encode(chunk);
+ const stream = new ReadableStream({
+ pull(controller) {
+ controller.enqueue(encoded);
+ controller.close();
+ },
+ });
+ return [stream, encoded];
+ };
+
+ beforeEach(() => {
+ htmlWriter = {
+ write: jest.fn(),
+ close: jest.fn(),
+ abort: jest.fn(),
+ };
+ jest.spyOn(HtmlStream.prototype, 'withChunkWriter').mockReturnValue(htmlWriter);
+ });
+
+ it('renders a single stream', async () => {
+ const [stream, encoded] = createSingleChunkStream(firstStreamContent);
+
+ await renderHtmlStreams([Promise.resolve(stream)], document.body);
+
+ expect(htmlWriter.write).toHaveBeenCalledWith(encoded);
+ expect(htmlWriter.close).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders stream sequence', async () => {
+ const [stream1, encoded1] = createSingleChunkStream(firstStreamContent);
+ const [stream2, encoded2] = createSingleChunkStream(secondStreamContent);
+
+ await renderHtmlStreams([Promise.resolve(stream1), Promise.resolve(stream2)], document.body);
+
+ expect(htmlWriter.write.mock.calls).toMatchObject([[encoded1], [encoded2]]);
+ expect(htmlWriter.close).toHaveBeenCalledTimes(1);
+ });
+
+ it("doesn't wait for the whole sequence to resolve before streaming", async () => {
+ const [stream1, encoded1] = createSingleChunkStream(firstStreamContent);
+ const [stream2, encoded2] = createSingleChunkStream(secondStreamContent);
+
+ let res;
+ const delayedStream = new Promise((resolve) => {
+ res = resolve;
+ });
+
+ renderHtmlStreams([Promise.resolve(stream1), delayedStream], document.body);
+
+ await waitForPromises();
+
+ expect(htmlWriter.write.mock.calls).toMatchObject([[encoded1]]);
+ expect(htmlWriter.close).toHaveBeenCalledTimes(0);
+
+ res(stream2);
+ await waitForPromises();
+
+ expect(htmlWriter.write.mock.calls).toMatchObject([[encoded1], [encoded2]]);
+ expect(htmlWriter.close).toHaveBeenCalledTimes(1);
+ });
+
+ it('closes HtmlStream on error', async () => {
+ const [stream1] = createSingleChunkStream(firstStreamContent);
+ const error = new Error();
+
+ try {
+ await renderHtmlStreams([Promise.resolve(stream1), Promise.reject(error)], document.body);
+ } catch (err) {
+ expect(err).toBe(error);
+ }
+
+ expect(htmlWriter.abort).toHaveBeenCalledTimes(1);
+ });
+});