diff --git a/cli/build.gradle b/cli/build.gradle index 7ead005..d86e74d 100644 --- a/cli/build.gradle +++ b/cli/build.gradle @@ -27,8 +27,21 @@ dependencies { // https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-core implementation 'org.apache.logging.log4j:log4j-core:2.19.0' + + /** + * TESTING + */ + // https://mvnrepository.com/artifact/org.junit.jupiter/junit-jupiter-api + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.8.2' + + // https://mvnrepository.com/artifact/org.junit.jupiter/junit-jupiter-engine + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.8.2' } application { mainClassName = 'com.jessebrault.ssg.StaticSiteGeneratorCli' +} + +test { + useJUnitPlatform() } \ No newline at end of file diff --git a/cli/src/main/groovy/com/jessebrault/ssg/StaticSiteGeneratorCli.groovy b/cli/src/main/groovy/com/jessebrault/ssg/StaticSiteGeneratorCli.groovy index 549415d..477efc0 100644 --- a/cli/src/main/groovy/com/jessebrault/ssg/StaticSiteGeneratorCli.groovy +++ b/cli/src/main/groovy/com/jessebrault/ssg/StaticSiteGeneratorCli.groovy @@ -32,7 +32,7 @@ class StaticSiteGeneratorCli implements Callable { private static final Logger logger = LogManager.getLogger(StaticSiteGeneratorCli) - private static class LogLevel { + static class LogLevel { @CommandLine.Option(names = ['--info'], description = 'Log at INFO level.') boolean info @@ -50,10 +50,12 @@ class StaticSiteGeneratorCli implements Callable { } @CommandLine.ArgGroup(exclusive = true, heading = 'Log Level') - private LogLevel logLevel + LogLevel logLevel @Override - Integer call() throws Exception { + Integer call() { + logger.traceEntry() + // Setup Loggers def context = (LoggerContext) LogManager.getContext(false) def configuration = context.getConfiguration() @@ -82,42 +84,51 @@ class StaticSiteGeneratorCli implements Callable { def defaultPartsProvider = new PartFilePartsProvider([gspPart], new File('parts')) def defaultSpecialPagesProvider = new SpecialPageFileSpecialPagesProvider([gspSpecialPage], new File('specialPages')) - def config = new Config( + def defaultConfig = new Config( textProviders: [defaultTextsProvider], templatesProviders: [defaultTemplatesProvider], partsProviders: [defaultPartsProvider], specialPagesProviders: [defaultSpecialPagesProvider] ) + def defaultGlobals = [:] - def globals = [:] + Collection builds = [] // Run build script, if applicable - if (new File('build.groovy').exists()) { - logger.info('found buildScript: build.groovy') + if (new File('ssgBuilds.groovy').exists()) { + logger.info('found buildScript: ssgBuilds.groovy') def buildScriptRunner = new GroovyBuildScriptRunner() - buildScriptRunner.runBuildScript(config, globals) - logger.debug('after running buildScript, config: {}', config) - logger.debug('after running buildScript, globals: {}', globals) + builds.addAll(buildScriptRunner.runBuildScript('ssgBuilds.groovy', defaultConfig, defaultGlobals)) + logger.debug('after running ssgBuilds.groovy, builds: {}', builds) } - // Generate - def ssg = new SimpleStaticSiteGenerator(config) - def result = ssg.generate(globals) - - if (result.v1.size() > 0) { - result.v1.each { - logger.error(it.message) - } - return 1 - } else { - def buildDir = new File('build') - result.v2.each { - def target = new File(buildDir, it.path + '.html') - target.createParentDirectories() - target.write(it.html) - } - return 0 + if (builds.empty) { + // Add default build + builds << new Build('default', defaultConfig, defaultGlobals, new File('build')) } + + // Get ssg object + def ssg = new SimpleStaticSiteGenerator() + + def hadDiagnostics = false + // Do each build + builds.each { + def result = ssg.generate(it) + if (result.v1.size() > 0) { + hadDiagnostics = true + result.v1.each { + logger.error(it.message) + } + } else { + result.v2.each { GeneratedPage generatedPage -> + def target = new File(it.outDir, generatedPage.path + '.html') + target.createParentDirectories() + target.write(generatedPage.html) + } + } + } + + logger.traceExit(hadDiagnostics ? 1 : 0) } } diff --git a/cli/src/test/groovy/com/jessebrault/ssg/StaticSiteGeneratorCliIntegrationTests.groovy b/cli/src/test/groovy/com/jessebrault/ssg/StaticSiteGeneratorCliIntegrationTests.groovy new file mode 100644 index 0000000..d1831d7 --- /dev/null +++ b/cli/src/test/groovy/com/jessebrault/ssg/StaticSiteGeneratorCliIntegrationTests.groovy @@ -0,0 +1,52 @@ +package com.jessebrault.ssg + +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.Test + +import static org.junit.jupiter.api.Assertions.assertEquals +import static org.junit.jupiter.api.Assertions.assertTrue + +class StaticSiteGeneratorCliIntegrationTests { + + @Test + @Disabled('until we figure out how to do the base dir arg') + void defaultConfiguration() { + def partsDir = new File('parts').tap { + mkdir() + deleteOnExit() + } + def specialPagesDir = new File('specialPages').tap { + mkdir() + deleteOnExit() + } + def templatesDir = new File('templatesDir').tap { + mkdir() + deleteOnExit() + } + def textsDir = new File('textsDir').tap { + mkdir() + deleteOnExit() + } + + new File(partsDir, 'part.gsp').write('<%= binding.test %>') + new File(specialPagesDir, 'specialPage.gsp').write('<%= parts.part.render([test: "Greetings!"]) %>') + new File(templatesDir, 'template.gsp').write('<%= text %>') + new File(textsDir, 'text.md').write('---\ntemplate: template.gsp\n---\n**Hello, World!**') + + StaticSiteGeneratorCli.main('--trace') + + def buildDir = new File('build').tap { + deleteOnExit() + } + assertTrue(buildDir.exists()) + + def textHtml = new File(buildDir, 'text.html') + assertTrue(textHtml.exists()) + assertEquals('

Hello, World!

\n', textHtml.text) + + def specialPage = new File(buildDir, 'specialPage.html') + assertTrue(specialPage.exists()) + assertEquals('Greetings!', specialPage.text) + } + +} diff --git a/lib/src/main/groovy/com/jessebrault/ssg/Build.groovy b/lib/src/main/groovy/com/jessebrault/ssg/Build.groovy new file mode 100644 index 0000000..c5d611a --- /dev/null +++ b/lib/src/main/groovy/com/jessebrault/ssg/Build.groovy @@ -0,0 +1,22 @@ +package com.jessebrault.ssg + +import groovy.transform.EqualsAndHashCode +import groovy.transform.NullCheck +import groovy.transform.TupleConstructor + +@TupleConstructor(defaults = false) +@NullCheck(includeGenerated = true) +@EqualsAndHashCode +class Build { + + String name + Config config + Map globals + File outDir + + @Override + String toString() { + "Build(name: ${ this.name }, config: ${ this.config }, globals: ${ this.globals }, outDir: ${ this.outDir })" + } + +} diff --git a/lib/src/main/groovy/com/jessebrault/ssg/Config.groovy b/lib/src/main/groovy/com/jessebrault/ssg/Config.groovy index 5c10dfc..f6c01c7 100644 --- a/lib/src/main/groovy/com/jessebrault/ssg/Config.groovy +++ b/lib/src/main/groovy/com/jessebrault/ssg/Config.groovy @@ -10,7 +10,7 @@ import groovy.transform.MapConstructor import groovy.transform.NullCheck import groovy.transform.TupleConstructor -@TupleConstructor +@TupleConstructor(force = true) @MapConstructor @NullCheck @EqualsAndHashCode @@ -21,6 +21,21 @@ class Config { Collection partsProviders Collection specialPagesProviders + Config(Config source) { + this.textProviders = [].tap { + addAll(source.textProviders) + } + this.templatesProviders = [].tap { + addAll(source.templatesProviders) + } + this.partsProviders = [].tap { + addAll(source.partsProviders) + } + this.specialPagesProviders = [].tap { + addAll(source.specialPagesProviders) + } + } + String toString() { "Config(textProviders: ${ this.textProviders }, templatesProviders: ${ this.templatesProviders }, " + "partsProviders: ${ this.partsProviders }, specialPagesProviders: ${ this.specialPagesProviders })" diff --git a/lib/src/main/groovy/com/jessebrault/ssg/SimpleStaticSiteGenerator.groovy b/lib/src/main/groovy/com/jessebrault/ssg/SimpleStaticSiteGenerator.groovy index bd3b358..d57209c 100644 --- a/lib/src/main/groovy/com/jessebrault/ssg/SimpleStaticSiteGenerator.groovy +++ b/lib/src/main/groovy/com/jessebrault/ssg/SimpleStaticSiteGenerator.groovy @@ -18,20 +18,21 @@ class SimpleStaticSiteGenerator implements StaticSiteGenerator { private static final Marker enter = MarkerFactory.getMarker('ENTER') private static final Marker exit = MarkerFactory.getMarker('EXIT') - private final Config config - @Override - Tuple2, Collection> generate(Map globals) { - logger.trace(enter, 'globals: {}', globals) + Tuple2, Collection> generate(Build build) { + logger.trace(enter, 'build: {}', build) + + def config = build.config // Get all texts, templates, parts, and specialPages - def texts = this.config.textProviders.collectMany { it.getTextFiles() } - def templates = this.config.templatesProviders.collectMany { it.getTemplates() } - def parts = this.config.partsProviders.collectMany { it.getParts() } - def specialPages = this.config.specialPagesProviders.collectMany { it.getSpecialPages() } + def texts = config.textProviders.collectMany { it.getTextFiles() } + def templates = config.templatesProviders.collectMany { it.getTemplates() } + def parts = config.partsProviders.collectMany { it.getParts() } + def specialPages = config.specialPagesProviders.collectMany { it.getSpecialPages() } logger.debug('\n\ttexts: {}\n\ttemplates: {}\n\tparts: {}\n\tspecialPages: {}', texts, templates, parts, specialPages) + def globals = build.globals Collection diagnostics = [] Collection generatedPages = [] diff --git a/lib/src/main/groovy/com/jessebrault/ssg/StaticSiteGenerator.groovy b/lib/src/main/groovy/com/jessebrault/ssg/StaticSiteGenerator.groovy index b0d14ef..8553be4 100644 --- a/lib/src/main/groovy/com/jessebrault/ssg/StaticSiteGenerator.groovy +++ b/lib/src/main/groovy/com/jessebrault/ssg/StaticSiteGenerator.groovy @@ -1,5 +1,5 @@ package com.jessebrault.ssg interface StaticSiteGenerator { - Tuple2, Collection> generate(Map globals) + Tuple2, Collection> generate(Build build) } diff --git a/lib/src/main/groovy/com/jessebrault/ssg/buildscript/BuildClosureDelegate.groovy b/lib/src/main/groovy/com/jessebrault/ssg/buildscript/BuildClosureDelegate.groovy new file mode 100644 index 0000000..6572ee3 --- /dev/null +++ b/lib/src/main/groovy/com/jessebrault/ssg/buildscript/BuildClosureDelegate.groovy @@ -0,0 +1,31 @@ +package com.jessebrault.ssg.buildscript + +import com.jessebrault.ssg.Config + +class BuildClosureDelegate { + + String name + Config config + Map globals + File outDir + + void config( + @DelegatesTo(value = ConfigClosureDelegate, strategy = Closure.DELEGATE_FIRST) + Closure configClosure + ) { + configClosure.setDelegate(new ConfigClosureDelegate(this.config)) + configClosure.setResolveStrategy(Closure.DELEGATE_FIRST) + configClosure.run() + } + + void globals( + @DelegatesTo(value = GlobalsClosureDelegate, strategy = Closure.DELEGATE_FIRST) + Closure globalsClosure + ) { + def globalsConfigurator = new GlobalsClosureDelegate(this.globals) + globalsClosure.setDelegate(globalsConfigurator) + globalsClosure.setResolveStrategy(Closure.DELEGATE_FIRST) + globalsClosure.run() + } + +} diff --git a/lib/src/main/groovy/com/jessebrault/ssg/buildscript/BuildScriptBase.groovy b/lib/src/main/groovy/com/jessebrault/ssg/buildscript/BuildScriptBase.groovy index 5262d49..2870dca 100644 --- a/lib/src/main/groovy/com/jessebrault/ssg/buildscript/BuildScriptBase.groovy +++ b/lib/src/main/groovy/com/jessebrault/ssg/buildscript/BuildScriptBase.groovy @@ -1,87 +1,33 @@ package com.jessebrault.ssg.buildscript +import com.jessebrault.ssg.Build import com.jessebrault.ssg.Config -import com.jessebrault.ssg.part.PartType -import com.jessebrault.ssg.specialpage.SpecialPageType -import com.jessebrault.ssg.template.TemplateType -import com.jessebrault.ssg.text.TextType -import groovy.transform.TupleConstructor abstract class BuildScriptBase extends Script { - static class ConfigClosureDelegate { + Config defaultConfig + Map defaultGlobals - @Delegate - private final Config config + Collection builds = [] - private final Collection defaultTextTypes - private final Collection defaultTemplateTypes - private final Collection defaultPartTypes - private final Collection defaultSpecialPageTypes + private int currentBuildNumber = 0 - ConfigClosureDelegate(Config config) { - this.config = config - this.defaultTextTypes = this.config.textProviders.collectMany { it.textTypes } - this.defaultTemplateTypes = this.config.templatesProviders.collectMany { it.templateTypes } - this.defaultPartTypes = this.config.partsProviders.collectMany { it.partTypes } - this.defaultSpecialPageTypes = this.config.specialPagesProviders.collectMany { it.specialPageTypes } - } - - Collection getDefaultTextTypes() { - this.defaultTextTypes - } - - Collection getDefaultTemplateTypes() { - this.defaultTemplateTypes - } - - Collection getDefaultPartTypes() { - this.defaultPartTypes - } - - Collection getDefaultSpecialPageTypes() { - this.defaultSpecialPageTypes - } - - } - - @TupleConstructor(includeFields = true, defaults = false) - static class GlobalsClosureDelegate { - - private final Map globals - - @Override - Object getProperty(String propertyName) { - this.globals[propertyName] - } - - @Override - void setProperty(String propertyName, Object newValue) { - this.globals.put(propertyName, newValue) - } - - } - - Config config - Map globals - - void config( - @DelegatesTo(value = ConfigClosureDelegate, strategy = Closure.DELEGATE_FIRST) - Closure configClosure + void build( + @DelegatesTo(value = BuildClosureDelegate, strategy = Closure.DELEGATE_FIRST) + Closure buildClosure ) { - configClosure.setDelegate(new ConfigClosureDelegate(this.config)) - configClosure.setResolveStrategy(Closure.DELEGATE_FIRST) - configClosure.run() - } - - void globals( - @DelegatesTo(value = GlobalsClosureDelegate, strategy = Closure.DELEGATE_FIRST) - Closure globalsClosure - ) { - def globalsConfigurator = new GlobalsClosureDelegate(this.globals) - globalsClosure.setDelegate(globalsConfigurator) - globalsClosure.setResolveStrategy(Closure.DELEGATE_FIRST) - globalsClosure.run() + def buildClosureDelegate = new BuildClosureDelegate().tap { + // Default values for Build properties + name = 'build' + currentBuildNumber + config = new Config(defaultConfig) + globals = new LinkedHashMap(defaultGlobals) + outDir = new File(name) + } + buildClosure.setDelegate(buildClosureDelegate) + buildClosure.setResolveStrategy(Closure.DELEGATE_FIRST) + buildClosure.run() + this.builds << new Build(buildClosureDelegate.name, buildClosureDelegate.config, buildClosureDelegate.globals, buildClosureDelegate.outDir) + currentBuildNumber++ } } diff --git a/lib/src/main/groovy/com/jessebrault/ssg/buildscript/BuildScriptRunner.groovy b/lib/src/main/groovy/com/jessebrault/ssg/buildscript/BuildScriptRunner.groovy index cdadaa2..2e85924 100644 --- a/lib/src/main/groovy/com/jessebrault/ssg/buildscript/BuildScriptRunner.groovy +++ b/lib/src/main/groovy/com/jessebrault/ssg/buildscript/BuildScriptRunner.groovy @@ -1,7 +1,8 @@ package com.jessebrault.ssg.buildscript +import com.jessebrault.ssg.Build import com.jessebrault.ssg.Config interface BuildScriptRunner { - void runBuildScript(Config config, Map globals) + Collection runBuildScript(String relativePath, Config defaultConfig, Map defaultGlobals) } \ No newline at end of file diff --git a/lib/src/main/groovy/com/jessebrault/ssg/buildscript/ConfigClosureDelegate.groovy b/lib/src/main/groovy/com/jessebrault/ssg/buildscript/ConfigClosureDelegate.groovy new file mode 100644 index 0000000..a9f5b64 --- /dev/null +++ b/lib/src/main/groovy/com/jessebrault/ssg/buildscript/ConfigClosureDelegate.groovy @@ -0,0 +1,43 @@ +package com.jessebrault.ssg.buildscript + +import com.jessebrault.ssg.Config +import com.jessebrault.ssg.part.PartType +import com.jessebrault.ssg.specialpage.SpecialPageType +import com.jessebrault.ssg.template.TemplateType +import com.jessebrault.ssg.text.TextType + +class ConfigClosureDelegate { + + @Delegate + private final Config config + + private final Collection defaultTextTypes + private final Collection defaultTemplateTypes + private final Collection defaultPartTypes + private final Collection defaultSpecialPageTypes + + ConfigClosureDelegate(Config config) { + this.config = config + this.defaultTextTypes = this.config.textProviders.collectMany { it.textTypes } + this.defaultTemplateTypes = this.config.templatesProviders.collectMany { it.templateTypes } + this.defaultPartTypes = this.config.partsProviders.collectMany { it.partTypes } + this.defaultSpecialPageTypes = this.config.specialPagesProviders.collectMany { it.specialPageTypes } + } + + Collection getDefaultTextTypes() { + this.defaultTextTypes + } + + Collection getDefaultTemplateTypes() { + this.defaultTemplateTypes + } + + Collection getDefaultPartTypes() { + this.defaultPartTypes + } + + Collection getDefaultSpecialPageTypes() { + this.defaultSpecialPageTypes + } + +} diff --git a/lib/src/main/groovy/com/jessebrault/ssg/buildscript/GlobalsClosureDelegate.groovy b/lib/src/main/groovy/com/jessebrault/ssg/buildscript/GlobalsClosureDelegate.groovy new file mode 100644 index 0000000..91fd452 --- /dev/null +++ b/lib/src/main/groovy/com/jessebrault/ssg/buildscript/GlobalsClosureDelegate.groovy @@ -0,0 +1,20 @@ +package com.jessebrault.ssg.buildscript + +import groovy.transform.TupleConstructor + +@TupleConstructor(includeFields = true, defaults = false) + class GlobalsClosureDelegate { + + private final Map globals + + @Override + Object getProperty(String propertyName) { + this.globals[propertyName] + } + + @Override + void setProperty(String propertyName, Object newValue) { + this.globals.put(propertyName, newValue) + } + +} diff --git a/lib/src/main/groovy/com/jessebrault/ssg/buildscript/GroovyBuildScriptRunner.groovy b/lib/src/main/groovy/com/jessebrault/ssg/buildscript/GroovyBuildScriptRunner.groovy index 034ed63..4facf3e 100644 --- a/lib/src/main/groovy/com/jessebrault/ssg/buildscript/GroovyBuildScriptRunner.groovy +++ b/lib/src/main/groovy/com/jessebrault/ssg/buildscript/GroovyBuildScriptRunner.groovy @@ -1,15 +1,16 @@ package com.jessebrault.ssg.buildscript +import com.jessebrault.ssg.Build import com.jessebrault.ssg.Config +import groovy.transform.NullCheck import org.codehaus.groovy.control.CompilerConfiguration import org.codehaus.groovy.control.customizers.ImportCustomizer +@NullCheck class GroovyBuildScriptRunner implements BuildScriptRunner { @Override - void runBuildScript(Config config, Map globals) { - Objects.requireNonNull(config) - Objects.requireNonNull(globals) + Collection runBuildScript(String relativePath, Config defaultConfig, Map defaultGlobals) { def engine = new GroovyScriptEngine([new File('.').toURI().toURL()] as URL[]) engine.config = new CompilerConfiguration().tap { addCompilationCustomizers(new ImportCustomizer().tap { @@ -24,11 +25,13 @@ class GroovyBuildScriptRunner implements BuildScriptRunner { }) scriptBaseClass = 'com.jessebrault.ssg.buildscript.BuildScriptBase' } - def buildScript = engine.createScript('build.groovy', new Binding()) + + def buildScript = engine.createScript(relativePath, new Binding()) assert buildScript instanceof BuildScriptBase - buildScript.config = config - buildScript.globals = globals + buildScript.defaultConfig = defaultConfig + buildScript.defaultGlobals = defaultGlobals buildScript.run() + buildScript.builds } } diff --git a/lib/src/test/groovy/com/jessebrault/ssg/SimpleStaticSiteGeneratorIntegrationTests.groovy b/lib/src/test/groovy/com/jessebrault/ssg/SimpleStaticSiteGeneratorIntegrationTests.groovy index 6ba2435..5a7c19d 100644 --- a/lib/src/test/groovy/com/jessebrault/ssg/SimpleStaticSiteGeneratorIntegrationTests.groovy +++ b/lib/src/test/groovy/com/jessebrault/ssg/SimpleStaticSiteGeneratorIntegrationTests.groovy @@ -14,11 +14,9 @@ import com.jessebrault.ssg.text.MarkdownTextRenderer import com.jessebrault.ssg.text.TextFileTextsProvider import com.jessebrault.ssg.text.TextType import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Disabled import org.junit.jupiter.api.Test -import static org.junit.jupiter.api.Assertions.assertEquals -import static org.junit.jupiter.api.Assertions.assertTrue +import static org.junit.jupiter.api.Assertions.* class SimpleStaticSiteGeneratorIntegrationTests { @@ -27,6 +25,7 @@ class SimpleStaticSiteGeneratorIntegrationTests { private File textsDir private File specialPagesDir + private Build build private StaticSiteGenerator ssg @BeforeEach @@ -52,7 +51,10 @@ class SimpleStaticSiteGeneratorIntegrationTests { partsProviders: [partsProvider], specialPagesProviders: [specialPagesProvider] ) - this.ssg = new SimpleStaticSiteGenerator(config) + def globals = [:] + + this.build = new Build('testBuild', config, globals, new File('build')) + this.ssg = new SimpleStaticSiteGenerator() } @Test @@ -60,7 +62,7 @@ class SimpleStaticSiteGeneratorIntegrationTests { new File(this.textsDir, 'test.md').write('---\ntemplate: test.gsp\n---\n**Hello, World!**') new File(this.templatesDir, 'test.gsp').write('<%= text %>') - def result = this.ssg.generate([:]) + def result = this.ssg.generate(this.build) assertTrue(result.v1.size() == 0) assertTrue(result.v2.size() == 1) @@ -80,7 +82,7 @@ class SimpleStaticSiteGeneratorIntegrationTests { new File(this.templatesDir, 'nested.gsp').write('<%= text %>') - def result = this.ssg.generate([:]) + def result = this.ssg.generate(this.build) assertTrue(result.v1.size() == 0) assertTrue(result.v2.size() == 1) @@ -91,20 +93,23 @@ class SimpleStaticSiteGeneratorIntegrationTests { } @Test - @Disabled('have to figure out what to do when we need just a plain text for a special page') void outputsSpecialPage() { new FileTreeBuilder(this.specialPagesDir).file('special.gsp', $/<%= texts.find { it.path == 'test' }.render() %>/$) new FileTreeBuilder(this.templatesDir).file('template.gsp', '<%= 1 + 1 %>') - new FileTreeBuilder(this.textsDir).file('test.md', 'Hello, World!') + new FileTreeBuilder(this.textsDir).file('test.md', '---\ntemplate: template.gsp\n---\nHello, World!') - def result = this.ssg.generate([:]) + def result = this.ssg.generate(this.build) assertEquals(0, result.v1.size()) assertEquals(2, result.v2.size()) - def p0 = result.v2[0] - assertEquals('special', p0.path) - assertEquals('

Hello, World!

\n', p0.html) + def testPage = result.v2.find { it.path == 'test' } + assertNotNull(testPage) + assertEquals('2', testPage.html) + + def specialPage = result.v2.find { it.path == 'special' } + assertNotNull(specialPage) + assertEquals('

Hello, World!

\n', specialPage.html) } }