diff --git a/__tests__/fetch_request.js b/__tests__/fetch_request.js index 649b84c..482da13 100644 --- a/__tests__/fetch_request.js +++ b/__tests__/fetch_request.js @@ -71,6 +71,20 @@ describe('perform', () => { expect(renderSpy).toHaveBeenCalledTimes(1) jest.clearAllMocks(); }) + + test('script request automatically calls activeScript', async () => { + const mockResponse = new Response('', { status: 200, headers: { 'Content-Type': 'application/javascript' }}) + window.fetch = jest.fn().mockResolvedValue(mockResponse) + jest.spyOn(FetchResponse.prototype, "ok", "get").mockReturnValue(true) + jest.spyOn(FetchResponse.prototype, "isScript", "get").mockReturnValue(true) + const renderSpy = jest.spyOn(FetchResponse.prototype, "activeScript").mockImplementation() + + const testRequest = new FetchRequest("get", "localhost") + await testRequest.perform() + + expect(renderSpy).toHaveBeenCalledTimes(1) + jest.clearAllMocks(); + }) }) test('treat method name case-insensitive', async () => { diff --git a/__tests__/fetch_response.js b/__tests__/fetch_response.js index f40de21..b73a65e 100644 --- a/__tests__/fetch_response.js +++ b/__tests__/fetch_response.js @@ -16,29 +16,29 @@ describe('body accessors', () => { test('works multiple times', async () => { const mockResponse = new Response("Mock", { status: 200, headers: new Headers({'Content-Type': 'text/plain'}) }) const testResponse = new FetchResponse(mockResponse) - + + expect(await testResponse.text).toBe("Mock") expect(await testResponse.text).toBe("Mock") - expect(await testResponse.text).toBe("Mock") }) test('work regardless of content-type', async () => { const mockResponse = new Response("Mock", { status: 200, headers: new Headers({'Content-Type': 'not/text'}) }) const testResponse = new FetchResponse(mockResponse) - - expect(await testResponse.text).toBe("Mock") + + expect(await testResponse.text).toBe("Mock") }) }) describe('html', () => { test('works multiple times', async () => { const mockResponse = new Response("

hi

", { status: 200, headers: new Headers({'Content-Type': 'application/html'}) }) const testResponse = new FetchResponse(mockResponse) - + + expect(await testResponse.html).toBe("

hi

") expect(await testResponse.html).toBe("

hi

") - expect(await testResponse.html).toBe("

hi

") }) test('rejects on invalid content-type', async () => { const mockResponse = new Response("

hi

", { status: 200, headers: new Headers({'Content-Type': 'text/plain'}) }) const testResponse = new FetchResponse(mockResponse) - + expect(testResponse.html).rejects.toBeInstanceOf(Error) }) }) @@ -46,7 +46,7 @@ describe('body accessors', () => { test('works multiple times', async () => { const mockResponse = new Response(JSON.stringify({ json: 'body' }), { status: 200, headers: new Headers({'Content-Type': 'application/json'}) }) const testResponse = new FetchResponse(mockResponse) - + // works mutliple times expect({ json: 'body' }).toStrictEqual(await testResponse.json) expect({ json: 'body' }).toStrictEqual(await testResponse.json) @@ -54,7 +54,7 @@ describe('body accessors', () => { test('rejects on invalid content-type', async () => { const mockResponse = new Response("

hi

", { status: 200, headers: new Headers({'Content-Type': 'text/json'}) }) const testResponse = new FetchResponse(mockResponse) - + expect(testResponse.json).rejects.toBeInstanceOf(Error) }) }) @@ -85,7 +85,7 @@ describe('body accessors', () => { const warningSpy = jest.spyOn(console, 'warn').mockImplementation() await testResponse.renderTurboStream() - + expect(warningSpy).toBeCalled() }) test('calls turbo', async () => { @@ -99,10 +99,18 @@ describe('body accessors', () => { test('rejects on invalid content-type', async () => { const mockResponse = new Response("

hi

", { status: 200, headers: new Headers({'Content-Type': 'text/plain'}) }) const testResponse = new FetchResponse(mockResponse) - + expect(testResponse.renderTurboStream()).rejects.toBeInstanceOf(Error) }) }) + describe('script', () => { + test('rejects on invalid content-type', async () => { + const mockResponse = new Response("", { status: 200, headers: new Headers({'Content-Type': 'text/plain'}) }) + const testResponse = new FetchResponse(mockResponse) + + expect(testResponse.activeScript()).rejects.toBeInstanceOf(Error) + }) + }) }) describe('fetch response helpers', () => { @@ -135,46 +143,46 @@ describe('fetch response helpers', () => { }) }) describe('http-status helpers', () => { - + test('200', () => { const mockResponse = new Response(null, { status: 200 }) const testResponse = new FetchResponse(mockResponse) - + expect(testResponse.statusCode).toBe(200) expect(testResponse.ok).toBeTruthy() - expect(testResponse.redirected).toBeFalsy() + expect(testResponse.redirected).toBeFalsy() expect(testResponse.unauthenticated).toBeFalsy() expect(testResponse.unprocessableEntity).toBeFalsy() }) - + test('401', () => { const mockResponse = new Response(null, { status: 401 }) const testResponse = new FetchResponse(mockResponse) - + expect(testResponse.statusCode).toBe(401) expect(testResponse.ok).toBeFalsy() - expect(testResponse.redirected).toBeFalsy() + expect(testResponse.redirected).toBeFalsy() expect(testResponse.unauthenticated).toBeTruthy() expect(testResponse.unprocessableEntity).toBeFalsy() }) - + test('422', () => { const mockResponse = new Response(null, { status: 422 }) const testResponse = new FetchResponse(mockResponse) - + expect(testResponse.statusCode).toBe(422) expect(testResponse.ok).toBeFalsy() - expect(testResponse.redirected).toBeFalsy() + expect(testResponse.redirected).toBeFalsy() expect(testResponse.unauthenticated).toBeFalsy() expect(testResponse.unprocessableEntity).toBeTruthy() }) - + test('302', () => { const mockHeaders = new Headers({'Location': 'https://localhost/login'}) const mockResponse = new Response(null, { status: 302, url: 'https://localhost/login', headers: mockHeaders }) jest.spyOn(mockResponse, 'redirected', 'get').mockReturnValue(true) const testResponse = new FetchResponse(mockResponse) - + expect(testResponse.statusCode).toBe(302) expect(testResponse.ok).toBeFalsy() expect(testResponse.redirected).toBeTruthy() diff --git a/__tests__/request_interceptor.js b/__tests__/request_interceptor.js index 57b3b2d..a708b31 100644 --- a/__tests__/request_interceptor.js +++ b/__tests__/request_interceptor.js @@ -1,11 +1,12 @@ /** * @jest-environment jsdom */ +import 'isomorphic-fetch' import { RequestInterceptor } from '../src/request_interceptor' import { FetchRequest } from '../src/fetch_request' beforeEach(() => { - window.fetch = jest.fn().mockResolvedValue({ status: 200, body: "done" }) + window.fetch = jest.fn().mockResolvedValue(new Response("success!", { status: 200, body: "done" })) }) test('request intercepter is executed', async () => { diff --git a/src/fetch_request.js b/src/fetch_request.js index a5f3038..2d27f01 100644 --- a/src/fetch_request.js +++ b/src/fetch_request.js @@ -29,6 +29,10 @@ export class FetchRequest { return Promise.reject(window.location.href = response.authenticationURL) } + if (response.isScript) { + await response.activeScript() + } + const responseStatusIsTurboStreamable = response.ok || response.unprocessableEntity if (responseStatusIsTurboStreamable && response.isTurboStream) { @@ -107,6 +111,8 @@ export class FetchRequest { return 'text/vnd.turbo-stream.html, text/html, application/xhtml+xml' case 'json': return 'application/json, application/vnd.api+json' + case 'script': + return 'text/javascript, application/javascript' default: return '*/*' } diff --git a/src/fetch_response.js b/src/fetch_response.js index 75d7725..8f853a0 100644 --- a/src/fetch_response.js +++ b/src/fetch_response.js @@ -61,6 +61,10 @@ export class FetchResponse { return this.contentType.match(/^text\/vnd\.turbo-stream\.html/) } + get isScript () { + return this.contentType.match(/\b(?:java|ecma)script\b/) + } + async renderTurboStream () { if (this.isTurboStream) { if (window.Turbo) { @@ -72,4 +76,17 @@ export class FetchResponse { return Promise.reject(new Error(`Expected a Turbo Stream response but got "${this.contentType}" instead`)) } } + + async activeScript () { + if (this.isScript) { + const script = document.createElement('script') + const metaTag = document.querySelector('meta[name=csp-nonce]') + const nonce = metaTag && metaTag.content + if (nonce) { script.setAttribute('nonce', nonce) } + script.innerHTML = await this.text + document.body.appendChild(script) + } else { + return Promise.reject(new Error(`Expected a Script response but got "${this.contentType}" instead`)) + } + } }