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 00000000000..f439cde89f1
--- /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 = '
headbody'
+ 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'))
+ page.addProperty('title', 'Title')
+ page.setTitleCaptured(true)
+ def writer = new StringWriter()
+
+ when:
+ page.writeHead(writer)
+
+ then:
+ writer.toString() == ''
+ 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 00000000000..c83e75e33bb
--- /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 = 'Title' +
+ 'Hello
'
+
+ when:
+ def page = new GrailsHTMLPageParser().parse(html.toCharArray())
+
+ then:
+ page.title == 'Title'
+ page.head == ''
+ page.body == 'Hello
'
+ 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 = 'Page title' +
+ 'Book titleBody'
+
+ when:
+ def page = new GrailsHTMLPageParser().parse(html.toCharArray())
+
+ then:
+ page.title == 'Page title'
+ page.body.contains('Book title')
+ page.body.endsWith('Body')
+ }
+
+ void 'parsed content exposes original head and body through the public Content interface'() {
+ given:
+ def html = 'TitleBody'
+ 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() == ''
+ 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 00000000000..48420b3cd26
--- /dev/null
+++ b/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/GrailsLayoutTagLibSpec.groovy
@@ -0,0 +1,112 @@
+/*
+ * 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.gsp.compiler.GrailsLayoutPreprocessor
+import org.grails.plugins.web.taglib.GrailsLayoutTagLib
+import spock.lang.Specification
+
+class GrailsLayoutTagLibSpec extends Specification implements TagLibUnitTest {
+
+ 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('Title' +
+ '' +
+ 'Body')
+
+ then:
+ output == 'TitleBody'
+ page.title == 'Title'
+ page.isTitleCaptured()
+ page.head.contains('')
+ page.body == 'Body'
+ page.getProperty('body.class') == 'content'
+ page.isUsed()
+ }
+
+ void 'wrapped title is captured in its own buffer so the layout head can drop it without reparsing'() {
+ when:
+ def output = applyTemplate('' +
+ 'Title' +
+ '')
+ def layoutHead = new StringWriter()
+ page.writeHead(layoutHead)
+
+ then:
+ output == 'Title'
+ page.title == 'Title'
+ page.isTitleCaptured()
+ page.titleBuffer != null
+ layoutHead.toString() == ''
+ }
+
+ void 'empty capture tags honour the preprocessor XML closing marker'() {
+ when:
+ def output = applyTemplate('')
+
+ then:
+ output == ''
+ page.getProperty('meta.viewport') == 'width'
+ }
+
+ void 'meta capture records both original and lowercase property names'() {
+ when:
+ def output = applyTemplate('' +
+ '')
+
+ then:
+ output == ''
+ 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('' +
+ 'Sidebar')
+
+ 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('Body') ==
+ 'Body'
+ applyTemplate('Title' +
+ '') == 'Title'
+ }
+}
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 00000000000..3e680a039d6
--- /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 = 'LayoutHello'
+ 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 = 'caf\u00e9'
+ 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('Error')
+
+ 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 00000000000..2ee1fed5a75
--- /dev/null
+++ b/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/GrailsViewBufferingResponseSpec.groovy
@@ -0,0 +1,161 @@
+/*
+ * 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 = 'Viewcaf\u00e9'
+
+ 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 is skipped once the target response is committed'() {
+ given:
+ def target = new MockHttpServletResponse()
+ target.setCommitted(true)
+ def response = new GrailsViewBufferingResponse(new MockHttpServletRequest(), target)
+
+ when:
+ response.sendError(500)
+ response.sendError(503, 'Unavailable')
+
+ then:
+ target.status == 200
+ 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 00000000000..74aab4da3c8
--- /dev/null
+++ b/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/LayoutDecoratorSpec.groovy
@@ -0,0 +1,125 @@
+/*
+ * 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 = 'OriginalOriginal body'
+ 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('Body'.toCharArray())
+ def model = [greeting: 'Hello']
+ def view = Mock(View)
+ def decorator = new SpringMVCViewDecorator('main', view)
+ def pageDuringRender = null
+ def layoutPageDuringRender = null
+
+ when:
+ decorator.render(content, model, request, response, request.servletContext)
+
+ then:
+ 1 * view.render(model, request, response) >> { Map values, servletRequest, servletResponse ->
+ pageDuringRender = servletRequest.getAttribute(RequestConstants.PAGE)
+ layoutPageDuringRender = servletRequest.getAttribute(EmbeddedGrailsLayoutView.GSP_GRAILS_LAYOUT_PAGE)
+ servletResponse.getWriter().write(values.greeting + ': Body')
+ }
+ pageDuringRender.body == 'Body'
+ layoutPageDuringRender instanceof GSPGrailsLayoutPage
+ 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 00000000000..86caa85a8f9
--- /dev/null
+++ b/grails-gsp/grails-layout/src/test/groovy/org/apache/grails/web/layout/RenderGrailsLayoutTagLibSpec.groovy
@@ -0,0 +1,102 @@
+/*
+ * 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 org.grails.taglib.GrailsTagException
+import spock.lang.Specification
+
+class RenderGrailsLayoutTagLibSpec extends Specification implements TagLibUnitTest {
+
+ GSPGrailsLayoutPage page
+
+ void setup() {
+ page = new GSPGrailsLayoutPage()
+ request.setAttribute(RequestConstants.PAGE, page)
+ }
+
+ void 'pageProperty renders a value, a default and an entire attribute'() {
+ given:
+ page.addProperty('body.class', 'content')
+
+ expect:
+ applyTemplate('') == 'content'
+ applyTemplate('') == ' class="content"'
+ applyTemplate('') == 'fallback'
+ applyTemplate('') == ''
+ }
+
+ void 'pageProperty requires a name attribute'() {
+ when:
+ applyTemplate('')
+
+ then:
+ def error = thrown(GrailsTagException)
+ error.message.contains('missing required attribute [name]')
+ }
+
+ 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('') == 'Sidebar'
+ applyTemplate('Present') == 'Present'
+ }
+
+ void 'ifPageProperty requires existing properties and matching values'() {
+ given:
+ page.addProperty('page.theme', 'dark')
+ page.addProperty('page.mode', 'wide')
+
+ expect:
+ applyTemplate('Yes') == 'Yes'
+ applyTemplate('Hidden') == ''
+ applyTemplate('Hidden') == ''
+ applyTemplate('Hidden') == ''
+ applyTemplate('Both') == 'Both'
+ }
+
+ void 'layout tags render captured title head and body with a default title when absent'() {
+ given:
+ def head = new FastStringWriter()
+ head.write('')
+ def body = new FastStringWriter()
+ body.write('Body')
+ page.setHeadBuffer(head.getBuffer())
+ page.setBodyBuffer(body.getBuffer())
+
+ expect:
+ applyTemplate('||') ==
+ 'Default||Body'
+
+ when:
+ page.addProperty('title', 'Title')
+
+ then:
+ applyTemplate('') == 'Title'
+ }
+}