You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by ja...@apache.org on 2026/10/13 21:35:18 UTC
(grails-core) 01/01: Add unit tests for Grails layout capture and response buffering.
This is an automated email from the ASF dual-hosted git repository.
jamesfredley pushed a commit to branch test/gsp-layout-coverage-7.0.x
in repository https://gitbox.apache.org/repos/asf/grails-core.git
commit 45828c7c7f62004079fc85caa12a0a963448573f
Author: James Fredley <ja...@users.noreply.github.com>
AuthorDate: Tue Oct 13 17:34:10 2026 -0400
Add unit tests for Grails layout capture and response buffering.
Cover page wrappers, HTML parsing, decorators, and layout tag capture.
Assisted-by: sisyphus-grok:grok-4.7
---
.../web/layout/GSPGrailsLayoutPageSpec.groovy | 155 ++++++++++++++
.../web/layout/GrailsHTMLPageParserSpec.groovy | 81 ++++++++
.../web/layout/GrailsLayoutTagLibSpec.groovy | 83 ++++++++
.../layout/GrailsPageResponseWrapperSpec.groovy | 223 +++++++++++++++++++++
.../layout/GrailsViewBufferingResponseSpec.groovy | 146 ++++++++++++++
.../grails/web/layout/LayoutDecoratorSpec.groovy | 121 +++++++++++
.../web/layout/RenderGrailsLayoutTagLibSpec.groovy | 92 +++++++++
7 files changed, 901 insertions(+)
diff --git a/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/GSPGrailsLayoutPageSpec.groovy b/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/GSPGrailsLayoutPageSpec.groovy
new file mode 100644
index 0000000000..f439cde89f
--- /dev/null
+++ b/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/GSPGrailsLayoutPageSpec.groovy
@@ -0,0 +1,155 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.grails.web.layout
+
+import org.grails.buffer.StreamCharBuffer
+import spock.lang.Specification
+
+class GSPGrailsLayoutPageSpec extends Specification {
+
+ void 'empty page writes no content and is unused'() {
+ given:
+ def page = new GSPGrailsLayoutPage()
+ def writer = new StringWriter()
+
+ when:
+ page.writeHead(writer)
+ page.writeBody(writer)
+ page.writePage(writer)
+
+ then:
+ writer.toString() == ''
+ page.getHead() == null
+ page.getBody() == null
+ page.getPage() == null
+ page.getTitleBuffer() == null
+ page.getContentBuffer('page.sidebar') == null
+ !page.isUsed()
+ !page.isTitleCaptured()
+ }
+
+ void 'captured buffers expose head body and original page separately'() {
+ given:
+ def page = new GSPGrailsLayoutPage(renderingLayout)
+ def original = '<html><head>head</head><body>body</body></html>'
+ page.setPageBuffer(buffer(original))
+ page.setHeadBuffer(buffer('head'))
+ page.setBodyBuffer(buffer('body'))
+ def head = new StringWriter()
+ def body = new StringWriter()
+ def full = new StringWriter()
+
+ when:
+ page.writeHead(head)
+ page.writeBody(body)
+ page.writeOriginal(full)
+
+ then:
+ page.isUsed()
+ page.getHead() == head.toString()
+ head.toString() == 'head'
+ page.getBody() == body.toString()
+ body.toString() == 'body'
+ page.getPage() == full.toString()
+ full.toString() == original
+ page.originalLength() == original.length()
+
+ where:
+ renderingLayout << [false, true]
+ }
+
+ void 'body output falls back to the whole page when no body has been captured'() {
+ given:
+ def page = new GSPGrailsLayoutPage()
+ page.setPageBuffer(buffer('fragment without a body tag'))
+ def writer = new StringWriter()
+
+ when:
+ page.writeBody(writer)
+
+ then:
+ writer.toString() == 'fragment without a body tag'
+ !page.isUsed()
+ }
+
+ void 'captured title is removed from head output but its property is retained'() {
+ given:
+ def page = new GSPGrailsLayoutPage()
+ page.setHeadBuffer(buffer('<TITLE class="main">Title</TITLE><meta name="layout" content="main">'))
+ page.addProperty('title', 'Title')
+ page.setTitleCaptured(true)
+ def writer = new StringWriter()
+
+ when:
+ page.writeHead(writer)
+
+ then:
+ writer.toString() == '<meta name="layout" content="main">'
+ page.title == 'Title'
+ page.isTitleCaptured()
+ }
+
+ void 'content and object properties mark the page as used'() {
+ given:
+ def page = new GSPGrailsLayoutPage()
+ def sidebar = buffer('Sidebar')
+
+ when:
+ page.addProperty('page.count', Integer.valueOf(3))
+ page.setContentBuffer('sidebar', sidebar)
+
+ then:
+ page.isUsed()
+ page.getProperty('page.count') == '3'
+ page.getProperty('page.sidebar') == ''
+ page.getContentBuffer('page.sidebar').is(sidebar)
+ page.getContentBuffer('page.missing') == null
+ GSPGrailsLayoutPage.content2htmlPage(page).is(page)
+ }
+
+ void 'reset clears captured content and flags so a page can be reused'() {
+ given:
+ def page = new GSPGrailsLayoutPage()
+ page.setHeadBuffer(buffer('head'))
+ page.setBodyBuffer(buffer('body'))
+ page.setPageBuffer(buffer('page'))
+ page.setTitleBuffer(buffer('title'))
+ page.setTitleCaptured(true)
+ page.setContentBuffer('sidebar', buffer('sidebar'))
+
+ when:
+ page.reset()
+
+ then:
+ !page.isUsed()
+ !page.isTitleCaptured()
+ page.getHead() == null
+ page.getBody() == null
+ page.getPage() == null
+ page.getTitleBuffer() == null
+ page.getContentBuffer('page.sidebar') == null
+ }
+
+ private static StreamCharBuffer buffer(String text) {
+ def buffer = new StreamCharBuffer()
+ buffer.getWriter().write(text)
+ buffer
+ }
+}
diff --git a/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/GrailsHTMLPageParserSpec.groovy b/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/GrailsHTMLPageParserSpec.groovy
new file mode 100644
index 0000000000..c83e75e33b
--- /dev/null
+++ b/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/GrailsHTMLPageParserSpec.groovy
@@ -0,0 +1,81 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.grails.web.layout
+
+import com.opensymphony.sitemesh.Content
+import spock.lang.Specification
+
+class GrailsHTMLPageParserSpec extends Specification {
+
+ void 'HTML parser extracts title head body and layout properties'() {
+ given:
+ def html = '<html lang="en"><head><title>Title</title><meta name="layout" content="main"></head>' +
+ '<body class="content"><p>Hello</p></body></html>'
+
+ when:
+ def page = new GrailsHTMLPageParser().parse(html.toCharArray())
+
+ then:
+ page.title == 'Title'
+ page.head == '<meta name="layout" content="main">'
+ page.body == '<p>Hello</p>'
+ page.getProperty('lang') == 'en'
+ page.getProperty('body.class') == 'content'
+ page.getProperty('meta.layout') == 'main'
+ }
+
+ void 'titles inside XML do not replace the HTML title'() {
+ given:
+ def html = '<html><head><title>Page title</title></head>' +
+ '<body><xml><book><title>Book title</title></book></xml>Body</body></html>'
+
+ when:
+ def page = new GrailsHTMLPageParser().parse(html.toCharArray())
+
+ then:
+ page.title == 'Page title'
+ page.body.contains('<title>Book title</title>')
+ page.body.endsWith('Body')
+ }
+
+ void 'parsed content exposes original head and body through the public Content interface'() {
+ given:
+ def html = '<html><head><title>Title</title><meta name="layout" content="main"></head><body>Body</body></html>'
+ Content content = new GrailsHTMLPageParser().parseContent(html.toCharArray())
+ def original = new StringWriter()
+ def head = new StringWriter()
+ def body = new StringWriter()
+
+ when:
+ content.writeOriginal(original)
+ content.writeHead(head)
+ content.writeBody(body)
+ content.addProperty('page.extra', 'value')
+
+ then:
+ original.toString() == html
+ content.originalLength() == html.length()
+ head.toString() == '<meta name="layout" content="main">'
+ body.toString() == 'Body'
+ content.title == 'Title'
+ content.getProperty('page.extra') == 'value'
+ GSPGrailsLayoutPage.content2htmlPage(content).body == 'Body'
+ }
+}
diff --git a/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/GrailsLayoutTagLibSpec.groovy b/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/GrailsLayoutTagLibSpec.groovy
new file mode 100644
index 0000000000..46d3ec7c81
--- /dev/null
+++ b/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/GrailsLayoutTagLibSpec.groovy
@@ -0,0 +1,83 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.grails.web.layout
+
+import grails.testing.web.taglib.TagLibUnitTest
+import org.grails.plugins.web.taglib.GrailsLayoutTagLib
+import spock.lang.Specification
+
+class GrailsLayoutTagLibSpec extends Specification implements TagLibUnitTest<GrailsLayoutTagLib> {
+
+ GSPGrailsLayoutPage page
+
+ void setup() {
+ page = new GSPGrailsLayoutPage()
+ request.setAttribute(EmbeddedGrailsLayoutView.GSP_GRAILS_LAYOUT_PAGE, page)
+ }
+
+ void 'capture tags render HTML and store head body and title on the page'() {
+ when:
+ def output = applyTemplate('<grailsLayout:captureHead><grailsLayout:captureTitle>Title</grailsLayout:captureTitle>' +
+ '<meta name="layout" content="main"></grailsLayout:captureHead>' +
+ '<grailsLayout:captureBody class="content">Body</grailsLayout:captureBody>')
+
+ then:
+ output == '<head><title>Title</title><meta name="layout" content="main"></head><body class="content">Body</body>'
+ page.title == 'Title'
+ page.isTitleCaptured()
+ page.head.contains('<meta name="layout" content="main">')
+ page.body == 'Body'
+ page.getProperty('body.class') == 'content'
+ page.isUsed()
+ }
+
+ void 'meta capture records both original and lowercase property names'() {
+ when:
+ def output = applyTemplate('<grailsLayout:captureMeta name="Description" content="Details"/>' +
+ '<grailsLayout:captureMeta http-equiv="Content-Type" content="text/html"/>')
+
+ then:
+ output == '<meta name="Description" content="Details"><meta http-equiv="Content-Type" content="text/html">'
+ page.getProperty('meta.Description') == 'Details'
+ page.getProperty('meta.description') == 'Details'
+ page.getProperty('meta.http-equiv.Content-Type') == 'text/html'
+ page.getProperty('meta.http-equiv.content-type') == 'text/html'
+ }
+
+ void 'parameter and content capture populate the page without writing the captured component'() {
+ when:
+ def output = applyTemplate('<grailsLayout:parameter name="theme" value="dark"/>' +
+ '<grailsLayout:captureContent tag="sidebar">Sidebar</grailsLayout:captureContent>')
+
+ then:
+ output == ''
+ page.getProperty('page.theme') == 'dark'
+ page.getContentBuffer('page.sidebar').toString() == 'Sidebar'
+ }
+
+ void 'capture tags still render without a layout page'() {
+ given:
+ request.removeAttribute(EmbeddedGrailsLayoutView.GSP_GRAILS_LAYOUT_PAGE)
+
+ expect:
+ applyTemplate('<grailsLayout:captureTitle/><grailsLayout:captureBody>Body</grailsLayout:captureBody>') ==
+ '<title></title><body>Body</body>'
+ }
+}
diff --git a/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/GrailsPageResponseWrapperSpec.groovy b/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/GrailsPageResponseWrapperSpec.groovy
new file mode 100644
index 0000000000..3e680a039d
--- /dev/null
+++ b/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/GrailsPageResponseWrapperSpec.groovy
@@ -0,0 +1,223 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.grails.web.layout
+
+import com.opensymphony.module.sitemesh.PageParserSelector
+import org.springframework.mock.web.MockHttpServletRequest
+import org.springframework.mock.web.MockHttpServletResponse
+import spock.lang.Specification
+
+class GrailsPageResponseWrapperSpec extends Specification {
+
+ MockHttpServletRequest request = new MockHttpServletRequest()
+ MockHttpServletResponse response = new MockHttpServletResponse()
+ PageParserSelector parserSelector = Stub(PageParserSelector) {
+ shouldParsePage('text/html') >> true
+ getPageParser('text/html') >> new GrailsHTMLPageParser()
+ }
+
+ void 'HTML written through the writer is buffered and parsed without committing the response'() {
+ given:
+ def wrapper = new GrailsPageResponseWrapper(request, response, parserSelector)
+ def html = '<html><head><title>Layout</title></head><body>Hello</body></html>'
+ wrapper.setContentType('text/html;charset=UTF-8')
+
+ when:
+ wrapper.getWriter().write(html)
+ wrapper.getWriter().flush()
+ wrapper.flushBuffer()
+ wrapper.setContentLength(html.length())
+ wrapper.setHeader('Content-Length', '200')
+ wrapper.addHeader('content-length', '300')
+ wrapper.setHeader('X-Layout', 'buffered')
+ wrapper.addHeader('X-Layout', 'parsed')
+
+ then:
+ wrapper.isGrailsLayoutActive()
+ !wrapper.isUsingStream()
+ new String(wrapper.getContents()) == html
+ wrapper.getPage().title == 'Layout'
+ wrapper.getPage().body == 'Hello'
+ response.contentAsString == ''
+ !response.committed
+ response.getHeader('Content-Length') == null
+ response.getHeaders('X-Layout') == ['buffered', 'parsed']
+ }
+
+ void 'HTML output stream uses the declared encoding'() {
+ given:
+ def wrapper = new GrailsPageResponseWrapper(request, response, parserSelector)
+ def html = '<html><body>caf\u00e9</body></html>'
+ wrapper.setHeader('Content-Type', 'text/html;charset=UTF-8')
+
+ when:
+ wrapper.getOutputStream().write(html.getBytes('UTF-8'))
+ def page = wrapper.getPage()
+
+ then:
+ wrapper.isUsingStream()
+ page.page == html
+ page.body == 'caf\u00e9'
+ response.contentAsByteArray.length == 0
+ }
+
+ void 'buffering disallows switching between writer and output stream'() {
+ given:
+ def wrapper = new GrailsPageResponseWrapper(request, response, parserSelector)
+ wrapper.setContentType('text/html')
+
+ when:
+ if (streamFirst) {
+ wrapper.getOutputStream().write(65)
+ wrapper.getWriter().write('B')
+ } else {
+ wrapper.getWriter().write('A')
+ wrapper.getOutputStream().write(66)
+ }
+
+ then:
+ thrown(IllegalStateException)
+
+ where:
+ streamFirst << [true, false]
+ }
+
+ void 'unparseable content is passed through with content length and flush'() {
+ given:
+ def wrapper = new GrailsPageResponseWrapper(request, response, parserSelector)
+
+ when:
+ wrapper.addHeader('Content-Type', 'text/plain')
+ wrapper.setContentLength(5)
+ wrapper.setHeader('Content-Length', '5')
+ wrapper.getWriter().write('plain')
+ wrapper.flushBuffer()
+
+ then:
+ !wrapper.isGrailsLayoutActive()
+ wrapper.getPage() == null
+ wrapper.getContents() == null
+ response.contentAsString == 'plain'
+ response.getHeader('Content-Length') == '5'
+ response.committed
+ }
+
+ void 'not modified status deactivates buffering and routes subsequent output to the target'() {
+ given:
+ def wrapper = new GrailsPageResponseWrapper(request, response, parserSelector)
+ wrapper.setContentType('text/html')
+ wrapper.getWriter().write('discarded')
+
+ when:
+ wrapper.setStatus(304)
+ wrapper.getWriter().write('target')
+ wrapper.getWriter().flush()
+
+ then:
+ response.status == 304
+ !wrapper.isGrailsLayoutActive()
+ response.contentAsString == 'target'
+ }
+
+ void 'error status clears buffered content before writing the error page'() {
+ given:
+ def wrapper = new GrailsPageResponseWrapper(request, response, parserSelector)
+ wrapper.setContentType('text/html')
+ wrapper.getWriter().write('discarded')
+
+ when:
+ wrapper.setStatus(500)
+ wrapper.getWriter().write('<html><body>Error</body></html>')
+
+ then:
+ response.status == 500
+ wrapper.getPage().body == 'Error'
+ }
+
+ void 'redirect and sendError discard buffered data and delegate status to the target'() {
+ given:
+ def wrapper = new GrailsPageResponseWrapper(request, response, parserSelector)
+ wrapper.setContentType('text/html')
+ wrapper.getWriter().write('discarded')
+
+ when:
+ switch (operation) {
+ case 'redirect':
+ wrapper.sendRedirect('/next')
+ break
+ case 'error':
+ wrapper.sendError(404)
+ break
+ case 'message':
+ wrapper.sendError(503, 'Unavailable')
+ break
+ }
+
+ then:
+ response.status == status
+ response.committed
+ response.redirectedUrl == location
+ response.errorMessage == message
+ wrapper.getContents() == null
+
+ where:
+ operation | status | location | message
+ 'redirect' | 302 | '/next' | null
+ 'error' | 404 | null | null
+ 'message' | 503 | null | 'Unavailable'
+ }
+
+ void 'captured GSP page is returned without reparsing and reset when layout is deactivated'() {
+ given:
+ def page = new GSPGrailsLayoutPage()
+ request.setAttribute(EmbeddedGrailsLayoutView.GSP_GRAILS_LAYOUT_PAGE, page)
+ response.setContentType('text/html')
+ def wrapper = new GrailsPageResponseWrapper(request, response, parserSelector)
+ page.addProperty('title', 'Captured')
+
+ expect:
+ wrapper.isGspGrailsLayoutActive()
+ wrapper.getPage().is(page)
+
+ when:
+ wrapper.deactivateGrailsLayout()
+
+ then:
+ !page.isUsed()
+ !wrapper.isGspGrailsLayoutActive()
+ wrapper.getPage() == null
+ }
+
+ void 'include and forward dispatch metadata is delegated through the wrapper'() {
+ given:
+ def wrapper = new GrailsPageResponseWrapper(request, response, parserSelector)
+
+ when:
+ request.getRequestDispatcher('/included').include(request, wrapper)
+ request.getRequestDispatcher('/forwarded').forward(request, wrapper)
+ wrapper.setStatus(201)
+
+ then:
+ response.includedUrls == ['/included']
+ response.forwardedUrl == '/forwarded'
+ wrapper.status == 201
+ response.status == 201
+ }
+}
diff --git a/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/GrailsViewBufferingResponseSpec.groovy b/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/GrailsViewBufferingResponseSpec.groovy
new file mode 100644
index 0000000000..2029790176
--- /dev/null
+++ b/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/GrailsViewBufferingResponseSpec.groovy
@@ -0,0 +1,146 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.grails.web.layout
+
+import org.springframework.mock.web.MockHttpServletRequest
+import org.springframework.mock.web.MockHttpServletResponse
+import spock.lang.Specification
+
+class GrailsViewBufferingResponseSpec extends Specification {
+
+ void 'view response buffers HTML written as characters or bytes and builds Content'() {
+ given:
+ def request = new MockHttpServletRequest()
+ def target = new MockHttpServletResponse()
+ target.setContentType('text/html;charset=UTF-8')
+ def response = new GrailsViewBufferingResponse(request, target)
+ def html = '<html><head><title>View</title></head><body>caf\u00e9</body></html>'
+
+ when:
+ if (usingStream) {
+ response.getOutputStream().write(html.getBytes('UTF-8'))
+ } else {
+ response.getWriter().write(html)
+ }
+ def content = response.getContent()
+ def body = new StringWriter()
+ content.writeBody(body)
+
+ then:
+ response.getTargetResponse().is(target)
+ response.isActive()
+ response.isUsingStream() == usingStream
+ content.title == 'View'
+ body.toString() == 'caf\u00e9'
+ target.contentAsByteArray.length == 0
+
+ where:
+ usingStream << [false, true]
+ }
+
+ void 'non HTML responses pass through without producing Content'() {
+ given:
+ def target = new MockHttpServletResponse()
+ def response = new GrailsViewBufferingResponse(new MockHttpServletRequest(), target)
+
+ when:
+ response.setContentType('application/json')
+ response.getWriter().write('{"value":1}')
+ response.getWriter().flush()
+
+ then:
+ !response.isActive()
+ response.getContent() == null
+ target.contentAsString == '{"value":1}'
+ }
+
+ void 'empty HTML writer has no Content to decorate'() {
+ given:
+ def response = new GrailsViewBufferingResponse(new MockHttpServletRequest(), new MockHttpServletResponse())
+ response.setContentType('text/html')
+ response.getWriter().write('')
+
+ expect:
+ response.isActive()
+ response.getContent() == null
+ }
+
+ void 'GSP capture is reused and response mutator deactivation stops buffering'() {
+ given:
+ def request = new MockHttpServletRequest()
+ def page = new GSPGrailsLayoutPage()
+ request.setAttribute(EmbeddedGrailsLayoutView.GSP_GRAILS_LAYOUT_PAGE, page)
+ def target = new MockHttpServletResponse()
+ def response = new GrailsViewBufferingResponse(request, target)
+ response.setContentType('text/html')
+ page.addProperty('title', 'Captured')
+
+ expect:
+ response.getContent().is(page)
+
+ when:
+ response.deactivateResponseMutator()
+ response.getWriter().write('passthrough')
+ response.getWriter().flush()
+
+ then:
+ !response.isActive()
+ !page.isUsed()
+ response.getContent() == null
+ target.contentAsString == 'passthrough'
+ }
+
+ void 'sendError after redirect does not replace the redirect status'() {
+ given:
+ def target = new MockHttpServletResponse()
+ def response = new GrailsViewBufferingResponse(new MockHttpServletRequest(), target)
+
+ when:
+ response.sendRedirect('/next')
+ response.sendError(500)
+ response.sendError(503, 'Unavailable')
+
+ then:
+ target.status == 302
+ target.redirectedUrl == '/next'
+ target.errorMessage == null
+ }
+
+ void 'sendError delegates status and message when the response is uncommitted'() {
+ given:
+ def target = new MockHttpServletResponse()
+ def response = new GrailsViewBufferingResponse(new MockHttpServletRequest(), target)
+
+ when:
+ if (message == null) {
+ response.sendError(404)
+ } else {
+ response.sendError(404, message)
+ }
+
+ then:
+ target.status == 404
+ target.errorMessage == message
+ target.committed
+
+ where:
+ message << [null, 'Missing']
+ }
+}
diff --git a/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/LayoutDecoratorSpec.groovy b/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/LayoutDecoratorSpec.groovy
new file mode 100644
index 0000000000..96b9daec69
--- /dev/null
+++ b/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/LayoutDecoratorSpec.groovy
@@ -0,0 +1,121 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.grails.web.layout
+
+import com.opensymphony.module.sitemesh.RequestConstants
+import com.opensymphony.sitemesh.webapp.SiteMeshWebAppContext
+import org.grails.web.util.GrailsApplicationAttributes
+import org.grails.web.util.WebUtils
+import org.springframework.mock.web.MockHttpServletRequest
+import org.springframework.mock.web.MockHttpServletResponse
+import org.springframework.web.servlet.View
+import spock.lang.Specification
+
+class LayoutDecoratorSpec extends Specification {
+
+ void 'no decorator writes the original page unchanged'() {
+ given:
+ def request = new MockHttpServletRequest()
+ def response = new MockHttpServletResponse()
+ def context = new SiteMeshWebAppContext(request, response, request.servletContext)
+ def html = '<html><head><title>Original</title></head><body>Original body</body></html>'
+ def content = new GrailsHTMLPageParser().parseContent(html.toCharArray())
+ def decorator = new GrailsNoDecorator()
+
+ when:
+ decorator.render(content, context)
+
+ then:
+ response.contentAsString == html
+ decorator.getName() == null
+ decorator.getPage() == null
+ decorator.getURIPath() == null
+ decorator.getRole() == null
+ decorator.getInitParameter('missing') == null
+ decorator.getInitParameterNames() == null
+ }
+
+ void 'view decorator makes page and model available during rendering and removes capture attributes afterwards'() {
+ given:
+ def request = new MockHttpServletRequest()
+ def response = new MockHttpServletResponse()
+ def content = new GrailsHTMLPageParser().parseContent('<html><body>Body</body></html>'.toCharArray())
+ def model = [greeting: 'Hello']
+ def view = Mock(View)
+ def decorator = new SpringMVCViewDecorator('main', view)
+
+ when:
+ decorator.render(content, model, request, response, request.servletContext)
+
+ then:
+ 1 * view.render(model, request, response) >> { Map values, servletRequest, servletResponse ->
+ assert servletRequest.getAttribute(RequestConstants.PAGE).body == 'Body'
+ assert servletRequest.getAttribute(EmbeddedGrailsLayoutView.GSP_GRAILS_LAYOUT_PAGE) instanceof GSPGrailsLayoutPage
+ servletResponse.getWriter().write(values.greeting + ': Body')
+ }
+ response.contentAsString == 'Hello: Body'
+ response.committed
+ request.getAttribute(RequestConstants.PAGE) == null
+ request.getAttribute(EmbeddedGrailsLayoutView.GSP_GRAILS_LAYOUT_PAGE) == null
+ decorator.getView().is(view)
+ decorator.getName() == 'main'
+ decorator.getTemplate() == null
+ }
+
+ void 'committed response skips layout rendering'() {
+ given:
+ def request = new MockHttpServletRequest()
+ def response = new MockHttpServletResponse()
+ response.setCommitted(true)
+ def view = Mock(View)
+ def decorator = new SpringMVCViewDecorator('main', view)
+ def context = new SiteMeshWebAppContext(request, response, request.servletContext)
+
+ when:
+ decorator.render(new GSPGrailsLayoutPage(), context)
+
+ then:
+ 0 * view.render(_, _, _)
+ request.getAttribute(RequestConstants.PAGE) == null
+ request.getAttribute(EmbeddedGrailsLayoutView.GSP_GRAILS_LAYOUT_PAGE) == null
+ }
+
+ void 'render failure is wrapped with layout name and clears page scope and layout selection'() {
+ given:
+ def request = new MockHttpServletRequest()
+ def response = new MockHttpServletResponse()
+ request.setAttribute(GrailsApplicationAttributes.PAGE_SCOPE, 'scope')
+ request.setAttribute(WebUtils.LAYOUT_ATTRIBUTE, 'main')
+ def cause = new IllegalStateException('Cannot render')
+ def view = Mock(View)
+ def decorator = new SpringMVCViewDecorator('main', view)
+
+ when:
+ decorator.render(new GSPGrailsLayoutPage(), [:], request, response, request.servletContext)
+
+ then:
+ 1 * view.render(_, request, response) >> { throw cause }
+ def error = thrown(RuntimeException)
+ error.message == 'Error applying layout : main'
+ error.cause.is(cause)
+ request.getAttribute(GrailsApplicationAttributes.PAGE_SCOPE) == null
+ request.getAttribute(WebUtils.LAYOUT_ATTRIBUTE) == null
+ }
+}
diff --git a/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/RenderGrailsLayoutTagLibSpec.groovy b/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/RenderGrailsLayoutTagLibSpec.groovy
new file mode 100644
index 0000000000..887f949bdc
--- /dev/null
+++ b/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/RenderGrailsLayoutTagLibSpec.groovy
@@ -0,0 +1,92 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+package org.apache.grails.web.layout
+
+import com.opensymphony.module.sitemesh.RequestConstants
+import grails.testing.web.taglib.TagLibUnitTest
+import org.grails.buffer.FastStringWriter
+import org.grails.plugins.web.taglib.RenderGrailsLayoutTagLib
+import spock.lang.Specification
+
+class RenderGrailsLayoutTagLibSpec extends Specification implements TagLibUnitTest<RenderGrailsLayoutTagLib> {
+
+ GSPGrailsLayoutPage page
+
+ void setup() {
+ page = new GSPGrailsLayoutPage()
+ request.setAttribute(RequestConstants.PAGE, page)
+ }
+
+ void 'pageProperty renders values default values and entire attributes'() {
+ given:
+ page.addProperty('body.class', 'content')
+
+ expect:
+ applyTemplate('<g:pageProperty name="body.class"/>') == 'content'
+ applyTemplate('<g:pageProperty name="body.class" writeEntireProperty="true"/>') == ' class="content"'
+ applyTemplate('<g:pageProperty name="missing" default="fallback"/>') == 'fallback'
+ applyTemplate('<g:pageProperty name="missing"/>') == ''
+ }
+
+ void 'captured component buffers can be rendered and tested as page properties'() {
+ given:
+ def writer = new FastStringWriter()
+ writer.write('Sidebar')
+ page.setContentBuffer('sidebar', writer.getBuffer())
+
+ expect:
+ applyTemplate('<g:pageProperty name="page.sidebar"/>') == 'Sidebar'
+ applyTemplate('<g:ifPageProperty name="page.sidebar">Present</g:ifPageProperty>') == 'Present'
+ }
+
+ void 'ifPageProperty requires existing properties and matching values'() {
+ given:
+ page.addProperty('page.theme', 'dark')
+ page.addProperty('page.mode', 'wide')
+
+ expect:
+ applyTemplate('<g:ifPageProperty name="page.theme" equals="dark">Yes</g:ifPageProperty>') == 'Yes'
+ applyTemplate('<g:ifPageProperty name="page.theme" equals="light">No</g:ifPageProperty>') == ''
+ applyTemplate('<g:ifPageProperty name="missing">No</g:ifPageProperty>') == ''
+ applyTemplate('<g:ifPageProperty>No</g:ifPageProperty>') == ''
+ applyTemplate('<g:ifPageProperty name="${[\'page.theme\', \'page.mode\']}" ' +
+ 'equals="${[\'dark\', \'wide\']}">Both</g:ifPageProperty>') == 'Both'
+ }
+
+ void 'layout tags render captured title head and body with a default title when absent'() {
+ given:
+ def head = new FastStringWriter()
+ head.write('<meta name="layout" content="main">')
+ def body = new FastStringWriter()
+ body.write('Body')
+ page.setHeadBuffer(head.getBuffer())
+ page.setBodyBuffer(body.getBuffer())
+
+ expect:
+ applyTemplate('<g:layoutTitle default="Default"/>|<g:layoutHead/>|<g:layoutBody/>') ==
+ 'Default|<meta name="layout" content="main">|Body'
+
+ when:
+ page.addProperty('title', 'Title')
+
+ then:
+ applyTemplate('<g:layoutTitle default="Default"/>') == 'Title'
+ }
+}