Archive

Posts Tagged ‘eclipse’

Writing an Eclipse Plug-in (Part 18): Common Navigator: Adding Submenus (Presentation)

February 7, 2010 2 comments

[Before you get too engaged in this post: you might want to skip it. Yes, I realize that the writing is engaging, witty, poetic, philosophical, daring, edgy, and technically astute, but what is all that to being wrong? Just jump forward to Part 18 – Take 2 for a refactoring of this post with many of the wrong parts taken out. However, the writing is still what it is and for that I offer my apologies.]

And now, the moment you’ve all been waiting for: adding commands to the popup menu.

I know, you are just aquiver in anticipation.

What menus do we need to put in? Just a few. The Custom Navigator popup menu should contain:

  • New – Sub-menu with choices (Custom Project, Schema, and Deployment. Eventually we will change Schema to Tables, Views, Filters)
  • Copy – Ctrl+C (an item in a category or an entire category)
  • Paste – Ctrl+V (an item in a category or an entire category)
  • Remove – Del or Ctrl+X (an item in a category or an entire category)

We will not worry about Import/Export behavior as that will be added to the Import/Export Wizard with the associated additions to the main menu, toolbar, etc. done as appropriate (meaning later, when I care).

The thing to remember is: presentation first, behavior second.

For now, we are going to set up the menus with bogus behavior. Since I don’t usually test the presentation this works out well for me. We’ll get around to behavior in the next post. Let’s get the menus in place and then we will snap the commands (next time).

Where should the popup menu configuration/code go: into the customplugin or customnavigator project? Because we are directly affecting the navigator popup menu the configuration will go into the customnavigator plug-in. I promise not to regret it later when someone points out why it would really be in the customplugin project.

How (are we doing it?)

First: let’s add one menu item to the New menu.

  1. customnavigator –> plugin.xml –> Extensions –> All Extensions –> Add –> org.eclipse.ui.menus
  2. org.eclipse.ui.menus –> New –> menuContribution
    • locationURI: popup:common.new.menu?after=additions
  3. popup:common.new.menu?after=additions (menuContribution) –> New –> command
    • commandId: org.eclipse.ui.newWizard
    • label: Custom Project
    • icon: icons/project-folder.png
  4. As a sanity check start the runtime workbench.

    Back to the grindstone.

  5. popup:common.new.menu?after=additions (menuContribution) –> New –> command
    • commandId: org.eclipse.ui.newWizard
    • label: Schema File
    • icon: icons/schema-file_16x16.png (copy this from the customplugin icon folder)
  6. popup:common.new.menu?after=additions (menuContribution) –> New –> command
    • commandId: org.eclipse.ui.newWizard
    • label: Deployment File
    • icon: icons/deployment-file_16x16.png (copy this from the customplugin icon folder)

Next: let’s add three standard menus: Copy/Paste/Delete. Again, for now we will assign default commands that we will change later.

Let’s use the following icons for the copy/paste/delete menu items (yes, I copied them from Eclipse):

  1. org.eclipse.ui.menus –> New –> menuContribution
    • locationURI: popup:customnavigator.navigator?before=import
  2. popup:customnavigator.navigator?before=import (menuContribution) –> New –> command
    • commandId: org.eclipse.ui.edit.copy
    • label: Copy
    • icon: icons/copy_16x16.png
  3. popup:customnavigator.navigator?before=import (menuContribution) –> New –> command
    • commandId: org.eclipse.ui.edit.paste
    • label: Paste
    • icon: icons/paste_16x16.png
  4. popup:customnavigator.navigator?before=import (menuContribution) –> New –> command
    • commandId: org.eclipse.ui.edit.delete
    • label: Delete
    • icon: icons/delete_16x16.png
  5. popup:customnavigator.navigator?before=import (menuContribution) –> New –> separator
    • name: customnavigator.separator
    • visible: true

The above is a lot to do all at once so feel free to add one section at a time and check the runtime workbench. Once you add the org.eclipse.ui.menus extension along with the menuContribution and one of the commands you should be able to open the Custom Perspective, right click in the Custom Navigator and see a lovely popup menu.

The final result is beautifully displayed in the screen capture (don’t sweat the fact that Delete is not disabled. That is under the control of Eclipse and the clipboard. You will probably have a different item enabled or none).

Don’t let the gratuitous use of Ctrl+N upset you. We will take care of messing those up later.

But first: time to reveal the magic.

Why (did we do it that way?)

If you want to add a menu item to the popup menu the easiest way is to use the org.eclipse.ui.menus extension and add a menuContribution to it (hmm. That would imply there is another way to do this. Yes, using actionSets…which have been deprecated, so don’t). The menuContribution‘s locationURI is the path to the location within the popup where your new menu item should go. If we were adding our menu item at the top level popup we would simply have used a locationURI of popup:customplugin.customnavigator?after=additions; that’s right: simply using the id of the navigator places the menu item into its popup. Using the after=additions piece puts the menu items directly in the popup right after the last item…which is not where we want it.

So the $54,000 question is: what locationURI do we use to put our menu items in the correct spot?

Before I can answer that we have to fix something. Well, some of us have to fix something.

For those of you doing this on Windows: shield your eyes and move on to the section titled CONTINUE HERE (while goto may be considered harmful, continue here isn’t).

Now that we have all of those miscreants out of the way: those of you who need to see the answer with your own eyes, but are using Kubuntu, need to change the Eclipse key binding for the runtime workbench from Shift+Alt+F2 to Shift+Alt+F3. Why? Because in order to discover the correct path to the New menu in the popup we need the Plug-in Spy to work. The Plug-in Spy, at least on Kubuntu, does not work properly because Shift+Alt+F2 doesn’t work properly; changing the key binding from Shift+Alt+F2 to Shift+Alt+F3 works.

How do we change the key binding? Start and do the following from the runtime workbench:

  1. Window –> Preferences –> General –> Keys
  2. In the text field below the Scheme drop down type: shift+alt+f
  3. Press Delete in the Binding field to remove the current key binding
  4. With the cursor in the Binding field press the shift key, the alt key and the F3 key all at the same time.
  5. Click OK to close the Preferences window

Now the bad news: you will have to do this every time you start the runtime workbench if you want to use the Plug-in Spy unless you unset the Launch configuration to clear the workspace; by not clearing the workspace you may find weird behavior that isn’t weird but appears to be weird because the runtime workbench is not cleaning up after itself. I recommend leaving the Launch configuration alone…meaning with the Clear selection checked.

The good news: you don’t need to use the Plug-in spy that often so this manual step is something you will not do too often. Grow up or start taking stronger meds.

CONTINUE HERE

Those of you on Windows: every time I say press Shift+Alt+F3 you must press Shift+Alt+F2. Got it? Shift+Alt+F3 really mean Shift+Alt+F2 (just in Windows). Think you can handle that?

  1. Start the runtime workbench and open the Custom Perspective
  2. Press Shift+Alt+F3. Notice the cursor changes appearance
  3. Right click in the Custom Navigator and select New –> Project
  4. Oh look! A window with wonderful information!

And there it is: the path used by the active contribution location URI: common.new.menu. Change the current path from popup:customplugin.customnavigator?after=additions to popup:common.new.menu?after=additions and start the runtime workbench again. The menus are exactly where we want them.

The configuration of the remaining menus now make sense. Add a new menuContribution to the org.eclipse.ui.menus extension and, using the high-level custom navigator path location URI (popup:customnavigator.navigator?before=import), we tell Eclipse to put the menus before the import section. Again, just to supply the required information we assign the internal copy, paste, and delete commandIds to the various menus.

Here are the instructions again:

  • org.eclipse.ui.menus –> New –> menuContribution
    • locationURI: popup:customnavigator.navigator?before=import
  • popup:customnavigator.navigator?before=import (menuContribution) –> New –> command
    • commandId: org.eclipse.ui.edit.copy
    • label: Copy
    • icon: icons/copy_16x16.png
  • popup:customnavigator.navigator?before=import (menuContribution) –> New –> command
    • commandId: org.eclipse.ui.edit.paste
    • label: Paste
    • icon: icons/paste_16x16.png
  • popup:customnavigator.navigator?before=import (menuContribution) –> New –> command
    • commandId: org.eclipse.ui.edit.delete
    • label: Delete
    • icon: icons/delete_16x16.png
  • popup:customnavigator.navigator?before=import (menuContribution) –> New –> separator
    • name: customnavigator.separator
    • visible: true

Doesn’t it look great? It just makes you proud to be a plug-in developer.

The next big question: why did we do that? Or more accurately: where is the documentation for this magic?

My answer is going to be very unsatisfying: I don’t know. I went through a number of plug-ins, web sites and help files and found nothing. When I remembered the Plug-in Spy, and fixed the Shift+Alt+F2 problem (Kubuntu, remember?), I was able to discover the path I was looking for.

However, that is not the reason why this post has taken so long to appear; I have had a lot on my mind and this wasn’t it. In the next post we will add command objects to the New menu items and ignore the Copy/Paste/Delete stuff (those commands need to be configured to recognize our custom types so they copy things properly. They might work our of the box, but since I haven’t thought through all of the implication I will assume that they will have to be changed).

Time to wash up:

  • Go to the Overview tab, click on Externalize Strings Wizard and externalize the strings
  • In plugin.xml click on the MANIFEST.MF tab, click on the light bulb in the left hand margin and select Add Missing Packages

Who’s better than you?

What Just Happened?

The popup menu items have been added! Without behavior!

In addition it was the Plug-in Spy that made it possible for me to create the proper locationURI I needed to insert the new menus in the correct spot.

Don’t underestimate the power of Plug-in Spy.

The cat is not only alive, but curious, which leads one to worry about the cat’s future.

(I hope this answered your question, Augusto.)

Code

No code…again.

bundle.properties

#Properties file for customnavigator
Bundle-Name = Custom Navigator Plug-in
view.name = Custom Plug-in Navigator
category.name = Custom Projects
navigatorContent.name = Custom Navigator Content
customProject.command.label = Custom Project
schemaFile.command.label = Schema File
deploymentFile.command.label = Deployment File
copy.command.label = Copy
paste.command.label = Paste
delete.command.label = Delete

plugin.xml

<?xml version="1.0" encoding="UTF-8"?>
<?eclipse version="3.4"?>
<plugin>
   <extension
         point="org.eclipse.ui.views">
      <category
            id="customnavigator.category"
            name="%category.name">
      </category>
      <view
            allowMultiple="false"
            category="customnavigator.category"
            class="org.eclipse.ui.navigator.CommonNavigator"
            icon="icons/navigator.png"
            id="customnavigator.navigator"
            name="%view.name">
      </view>
   </extension>
   <extension
         point="org.eclipse.ui.navigator.viewer">
      <viewerActionBinding
            viewerId="customnavigator.navigator">
         <includes>
            <actionExtension
                  pattern="org.eclipse.ui.navigator.resources.*">
            </actionExtension>
         </includes>
      </viewerActionBinding>
      <viewerContentBinding
            viewerId="customnavigator.navigator">
         <includes>
            <contentExtension
                  pattern="customnavigator.navigatorContent">
            </contentExtension>
         </includes>
      </viewerContentBinding>
   </extension>
   <extension
         point="org.eclipse.ui.navigator.navigatorContent">
      <navigatorContent
            activeByDefault="true"
            contentProvider="customnavigator.navigator.ContentProvider"
            id="customnavigator.navigatorContent"
            labelProvider="customnavigator.navigator.LabelProvider"
            name="%navigatorContent.name">
         <triggerPoints>
            <instanceof
                  value="org.eclipse.core.resources.IWorkspaceRoot">
            </instanceof>
         </triggerPoints>
         <commonSorter
               class="customnavigator.sorter.SchemaCategorySorter"
               id="customnavigator.sorter.schemacategorysorter">
            <parentExpression>
               <or>
                  <instanceof
                        value="customnavigator.navigator.CustomProjectSchema">
                  </instanceof>
               </or>
            </parentExpression>
         </commonSorter>
      </navigatorContent>
   </extension>
   <extension
         point="org.eclipse.ui.menus">
      <menuContribution
            locationURI="popup:common.new.menu?after=additions">
         <command
               commandId="org.eclipse.ui.newWizard"
               icon="icons/project-folder.png"
               label="%customProject.command.label"
               style="push">
         </command>
         <command
               commandId="org.eclipse.ui.newWizard"
               icon="icons/schema-file_16x16.png"
               label="%schemaFile.command.label"
               style="push">
         </command>
         <command
               commandId="org.eclipse.ui.newWizard"
               icon="icons/deployment-file_16x16.png"
               label="%deploymentFile.command.label"
               style="push">
         </command>
      </menuContribution>
      <menuContribution
            locationURI="popup:customnavigator.navigator?before=import">
         <command
               commandId="org.eclipse.ui.edit.copy"
               icon="icons/copy_16x16.png"
               label="%copy.command.label"
               style="push">
         </command>
         <command
               commandId="org.eclipse.ui.edit.paste"
               icon="icons/paste_16x16.png"
               label="%paste.command.label"
               style="push">
         </command>
         <command
               commandId="org.eclipse.ui.edit.delete"
               icon="icons/delete_16x16.png"
               label="%delete.command.label"
               style="push">
         </command>
         <separator
               name="customnavigator.separator"
               visible="true">
         </separator>
      </menuContribution>
   </extension>

</plugin>

Writing an Eclipse Plug-in (Part 17): Custom Project: Customizing the Perspective Menus Using Customize Perspective

January 9, 2010 2 comments

Almost time to add submenus!

But not today. I said almost.

Today, I want to take a look at the Customize Perspective dialog, what we got for free and what we still have to do to get that final finished look.

Back in Part 15 I mentioned that we should update three things when we add a New Wizard:

  1. Main menu File menu
  2. Toolbar
  3. Customize Perspective window

In Part 15 we updated the File menu, in part 16 we updated the toolbar and now we will take a look at what needs to be done to allow us to customize the custom perspective’s version of the File menu and the toolbar.

Before we do that let’s take a look at what Eclipse gave us for free and review how a user might customize a perspective.

Start the runtime workbench, switch to the Custom Perspective and open the Customize Perspective window by doing the following:

  • Window –> Open Perspective –> Other –> Custom Plug-in Perspective –> OK
  • Window –> Customize Perspective

Click on the Menu Visibility tab.

From the Menu Structure list we can see that our three New Wizard types are already listed. If a user were to decide that they did not want to see one or more of the custom wizards they could simply uncheck the undesired types. Simply adding the items to perspectiveExtensions as newWizardShortcuts added them to the File –> New main menu, the toolbar New button and this user configuration window.

Clicking on the Command Groups Availability tab does not show us anything related to our current task.

Click on the Shortcuts tab.

The Submenus choice of New has our Custom Wizards all selected. Anything checked/unchecked here will have the same impact as the Menu Visibility tab; things will appear or disappear from the main menu File –> New submenu and from the toolbar New drop down button.

The Submenus choice of Open Perspective will display the checked item under the main menu Window –> Open Perspective submenu. Since none of the items are check none of them appear when the user selects Window –> Open Perspective (they see the ubiquitous Other).

The Submenus choice of Show View will display the checked item under the main menu Window –> Show View. Again, none are chosen so Other will be seen instead.

Click on the Tool Bar Visibility tab. Be prepared to cover your eyes; it could get ugly.

The Tool Bar Structure list shows the second entry as existing, having three entries, but no label. What happened?

Way back in the history of Eclipse, say the 3.5 release, the Platform Command Framework was introduced. It deprecated the use of the actionSets extension to both make adding commands simpler and more flexible. The recommended way of doing what we did was the way we did it: use the org.eclipse.ui.menus extension. There is only one problem: the actionSets extension is still useful and is the missing part of today’s puzzle.

Today’s puzzle: how do we give our toolbar group a label?

I’m glad you asked.

How (are we doing it?)

Let’s walk the steps to give our custom wizard toolbar commands a label.

  1. plugin.xml –>Extensions –> Add
  2. Select org.eclipse.ui.actionSets and click Finish
  3. Change label (actionSet) to:
    • id: customplugin.toolbar
    • label: Hidden Clause Toolbar Commands
  4. Save plugin.xml
  5. Start the runtime workbench
  6. Change to the Custom Perspective and go to Window –> Customize Perspective

Woo hoo! Yes, success tastes sweet.

Now use the Externalize Strings Wizard to move the string Hidden Clause Toolbar Commands to our properties file.

Why (did we do it that way?)

As you may have already guessed, not using the actionSets extension had 0 impact on the behavior of the buttons we added. None. Nada. Zilch.

That didn’t stop me from spending a few hours to track down the proper configuration to make sure that I had a respectable label for my new toolbar buttons. Why bother spending the time? Because it matters to the user even if that user is me or my most hated enemy (well, maybe not my most hated enemy).

In this case, the use of the actionSets extension just got us our label and burned part of my Saturday afternoon. The use of actionSets continues the configuration-only streak I like so much.

Hey, if I can’t burn a few hours to help spread the good word about Eclipse then what can I do?

The cat is alive and has company.

What Just Happened?

With a simple addition to plugin.xml we were able to complete a successful addition of New Wizard functionality to the main menu, the toolbar and the Customize Perspective window. I’m happy. I hope you are too (but that doesn’t matter to me so much).

Thanks

Thanks to Michael Scharf at the Eclipse and Java Blog for just the example I was looking for on how to give the Customize Perspective Tool Bar Visibility toolbar group a label using just configuration.

Reference

Every pixel counts! The new eclipse fullscreen plugin…

Code

No code! Again!

plugin.xml

<?xml version="1.0" encoding="UTF-8"?>
<?eclipse version="3.2"?>
<plugin>
   <extension
         point="org.eclipse.ui.newWizards">
      <category
            id="customplugin.category.wizards"
            name="%category.name">
      </category>
      <wizard
            category="customplugin.category.wizards"
            class="customplugin.wizards.CustomProjectNewWizard"
            finalPerspective="customplugin.perspective"
            icon="icons/project-folder.png"
            id="customplugin.wizard.new.custom"
            name="%wizard.name">
      </wizard>
      <wizard
            category="customplugin.category.wizards"
            class="customplugin.wizards.CustomProjectNewSchemaFile"
            descriptionImage="icons/schema-file_32x32.png"
            icon="icons/schema-file_16x16.png"
            id="customplugin.wizard.file.schema"
            name="%wizard.name.schema">
      </wizard>
      <wizard
            category="customplugin.category.wizards"
            class="customplugin.wizards.CustomProjectNewDeploymentFile"
            descriptionImage="icons/deployment-file_32x32.png"
            icon="icons/deployment-file_16x16.png"
            id="customplugin.wizard.file.deployment"
            name="%wizard.name.deployment">
      </wizard>
   </extension>
   <extension
         point="org.eclipse.ui.perspectives">
      <perspective
            class="customplugin.perspectives.Perspective"
            icon="icons/perspective.png"
            id="customplugin.perspective"
            name="%perspective.name">
      </perspective>
   </extension>
   <extension
         point="org.eclipse.ui.perspectiveExtensions">
      <perspectiveExtension
            targetID="customplugin.perspective">
         <view
               id="customnavigator.navigator"
               minimized="false"
               ratio=".25"
               relationship="left"
               relative="org.eclipse.ui.editorss">
         </view>
      </perspectiveExtension>
   </extension>
   <extension
         id="customplugin.projectNature"
         point="org.eclipse.core.resources.natures">
      <runtime>
         <run
               class="customplugin.natures.ProjectNature">
         </run>
      </runtime>
   </extension>
   <extension
         point="org.eclipse.ui.ide.projectNatureImages">
      <image
            icon="icons/project-folder.png"
            id="customplugin.natureImage"
            natureId="customplugin.projectNature">
      </image>
   </extension>
   <extension
         id="customplugin.contenttype"
         point="org.eclipse.core.contenttype.contentTypes">
      <content-type
            base-type="org.eclipse.core.runtime.xml"
            file-extensions="xml"
            id="customplugin.contenttype.schema"
            name="%content-type.name.schema"
            priority="normal">
         <describer
               class="org.eclipse.core.runtime.content.XMLRootElementContentDescriber2">
            <parameter
                  name="element"
                  value="hc-schema">
            </parameter>
         </describer>
      </content-type>
      <content-type
            base-type="org.eclipse.core.runtime.xml"
            file-extensions="xml"
            id="customplugin.contenttype.deployment"
            name="%content-type.name.deployment"
            priority="normal">
         <describer
               class="org.eclipse.core.runtime.content.XMLRootElementContentDescriber2">
            <parameter
                  name="element"
                  value="hc-deployment">
            </parameter>
         </describer>
      </content-type>
   </extension>
   <extension
         point="org.eclipse.ui.perspectiveExtensions">
      <perspectiveExtension
            targetID="customplugin.perspective">
         <newWizardShortcut
               id="customplugin.wizard.new.custom">
         </newWizardShortcut>
         <newWizardShortcut
               id="customplugin.wizard.file.schema">
         </newWizardShortcut>
         <newWizardShortcut
               id="customplugin.wizard.file.deployment">
         </newWizardShortcut>
      </perspectiveExtension>
   </extension>
   <extension
         point="org.eclipse.ui.menus">
      <menuContribution
            locationURI="toolbar:org.eclipse.ui.main.toolbar">
         <toolbar
               id="customplugin.toolbar">
            <command
                  commandId="org.eclipse.ui.newWizard"
                  icon="icons/project-folder.png"
                  label="%customproject.label"
                  style="push"
                  tooltip="%customproject.tooltip">
               <parameter
                     name="newWizardId"
                     value="customplugin.wizard.new.custom">
               </parameter>
               <visibleWhen
                     checkEnabled="false">
                  <with
                        variable="activeWorkbenchWindow.activePerspective">
                     <equals
                           value="customplugin.perspective">
                     </equals>
                  </with>
               </visibleWhen>
            </command>
            <command
                  commandId="org.eclipse.ui.newWizard"
                  icon="icons/schema-file_16x16.png"
                  label="%schema.label"
                  style="push"
                  tooltip="%schema.tooltip">
               <parameter
                     name="newWizardId"
                     value="customplugin.wizard.file.schema">
               </parameter>
               <visibleWhen
                     checkEnabled="false">
                  <with
                        variable="activeWorkbenchWindow.activePerspective">
                     <equals
                           value="customplugin.perspective">
                     </equals>
                  </with>
               </visibleWhen>
            </command>
            <command
                  commandId="org.eclipse.ui.newWizard"
                  icon="icons/deployment-file_16x16.png"
                  label="%deployment.label"
                  style="push"
                  tooltip="%deployment.tooltip">
               <parameter
                     name="newWizardId"
                     value="customplugin.wizard.file.deployment">
               </parameter>
               <visibleWhen
                     checkEnabled="false">
                  <with
                        variable="activeWorkbenchWindow.activePerspective">
                     <equals
                           value="customplugin.perspective">
                     </equals>
                  </with>
               </visibleWhen>
            </command>
         </toolbar>
      </menuContribution>
   </extension>
   <extension
         point="org.eclipse.ui.actionSets">
      <actionSet
            id="customplugin.toolbar"
            label="Hidden Clause Toolbar Commands">
      </actionSet>
   </extension>

</plugin>

bundle.properties

#Properties file for customplugin
Bundle-Name = Customplugin Plug-in
category.name = Custom Wizards
wizard.name = Custom Project
perspective.name = Custom Plug-in Perspective
content-type.name.schema = Hidden Clause Schema Definition
content-type.name.deployment = Hidden Clause Deployment Definition
wizard.name.schema = Schema File
wizard.name.deployment = Deployment File

customproject.label = New Custom Project
customproject.tooltip = New Custom Project
schema.label = New Schema File
schema.tooltip = New Schema File
deployment.label = New Deployment File
deployment.tooltip = New Deployment File
toolbar.actionSet.label = Hidden Clause Toolbar Commands

Writing an Eclipse Plug-in (Part 16): Custom Project: Customizing the Perspective Menus (Toolbar)

January 8, 2010 1 comment

In this post we will add three custom toolbar buttons to the main toolbar of the Custom Perspective of the Eclipse workbench.

Before we do that, I would like to point out that one of the default buttons on the toolbar is the New button. When you click on the downward pointing arrow of the New button you will see something shocking (shocking I say!): the same entries as we added to File –> New appear in the New toolbar button. That’s because they are tied together; change one and you change the other. We could disable it, but why not leave well enough alone? While it is true we didn’t mean to turn on this behavior we can bask in the glory of a job accidentally well done.

Life is full of disappointments.

Today we will add three buttons to the toolbar and have them create either a new Custom Project, Schema file or Deployment file. Seems kinda redundant in light of the behavior of the New toolbar button, but I want single custom command buttons in any case.

We need 3 new images; one for each button. I am reusing the images from a Hidden Clause Custom Project, Schema file and Deployment file:

How (are we doing it?)

Let’s add the buttons to the toolbar together with the configuration needed to open the appropriate New Wizards.

  1. Open plugin.xml
  2. Add –> org.eclipse.ui.menus
  3. org.eclipse.ui.menus –> menuContribution
    • locationURI: toolbar:org.eclipse.ui.main.toolbar
  4. toolbar:org.eclipse.ui.main.toolbar (menuContribution) –> toolbar
    • id: customplugin.toolbar
  5. customplugin.toolbar (toolbar) –> command
    • commandId: org.eclipse.ui.newWizard
    • label: New Custom Project
    • icon: icons/project-folder.png
    • tooltip: New Custom Project
    • style: push
  6. New Custom Project (command) –> parameter
    • name: newWizardId
    • value: customplugin.wizard.new.custom
  7. customplugin.toolbar (toolbar) –> command
    • commandId: org.eclipse.ui.newWizard
    • label: New Schema File
    • icon: icons/schema-file_16x16.png
    • tooltip: New Schema File
    • style: push
  8. New Schema File (command)–> parameter
    • name: newWizardId
    • value: customplugin.wizard.file.schema
  9. customplugin.toolbar (toolbar) –> command
    • commandId: org.eclipse.ui.newWizard
    • label: New Deployment File
    • icon: icons/deployment-file_16x16.png
    • tooltip: New Deployment File
    • style: push
  10. New Deployment File (command)–> parameter
    • name: newWizardId
    • value: customplugin.wizard.file.deployment
  11. Save plugin.xml
  12. Start the runtime workbench. Pressing any of the new buttons should open the appropriate New Wizard
  13. Hold the mouse over the buttons; the tooltips should display the string for which we configured each button

Oh oh! A bug! The toolbar buttons appear in all of the perspectives not just in the Custom Perspective. That behavior is just offensive; okay, maybe not offensive, but wrong in this case.

We will fix that with the following configuration.

  1. New Custom Project (command) –> visibleWhen
    • false (visibleWhen) –> with
      • variable: activeWorkbenchWindow.activePerspective
        • activeWorkbenchWindow.activePerspective (with) –> equals
          • value: customplugin.perspective
  2. New Schema File (command)–> visibleWhen
    • false (visibleWhen) –> with
      • variable: activeWorkbenchWindow.activePerspective
        • activeWorkbenchWindow.activePerspective (with) –> equals
          • value: customplugin.perspective
  3. New Deployment File (command)–> visibleWhen
    • false (visibleWhen) –> with
      • variable: activeWorkbenchWindow.activePerspective
        • activeWorkbenchWindow.activePerspective (with) –> equals
          • value: customplugin.perspective

So where did the variable name activeWorkbenchWindow.activePerspective come from? From Eclipse, of course! Oh, okay, take a look at the Command Core Expressions entry in the Eclipse wiki for more interesting variables you can use in your elements. We won’t be using any others…this time.

Why (did we do it that way?)

There is a lot of configuration going on here. There are only two obscure/interesting points.

Step #3:

  • org.eclipse.ui.menus –> menuContribution
    • locationURI: toolbar:org.eclipse.ui.main.toolbar

The locationURI field tells Eclipse where to place the three buttons: in the toolbar (hence the use of a scheme named toolbar) and which toolbar to use (the main toolbar which has an id of org.eclipse.ui.main.toolbar). Makes sense once you know it, but it would be helpful to have more examples. But that’s just me.

Step #1, 2 and 3 take care of hiding the toolbar buttons in all perspectives except the Custom Perspective.

  • false (visibleWhen) –> with
    • variable: activeWorkbenchWindow.activePerspective
      • activeWorkbenchWindow.activePerspective (with) –> equals
        • value: customplugin.perspective

The visibleWhen element works with the usual choices of adapt, and, count, equals, etc. By selecting the with element you have to supply one of the variables listed in the Command Core Expressions listed in the Eclipse wiki. At runtime the variable activeWorkbenchWindow.activePerspective contains the id of the current perspective so including the equals element with the id of the Custom Perspective (customplugin.perspective) means that the only time the selected button will appear is when the Custom Perspective is open.

Don’t forget to open plugin.xml and externalize the new strings otherwise you will be stuck with a bunch of warnings that are not worth tolerating.

What Just Happened?

In today’s episode:

  • we added 3 buttons to the main toolbar
  • configured the toolbar buttons to open the appropriate wizards
  • configured the toolbar buttons to only appear in the custom perspective

Once again, we have managed to add a significant amount of behavior and not had to write any code. It doesn’t get any better than that (well, maybe it does, but I’m not sure if I’m ready to brag about that kind of thing).

Thanks

David Carver and his blog entry Adding Wizards To Toolbars.

Code

Oh, yeah. None.

However, there are the entries in the plugin.xml file.

<?xml version="1.0" encoding="UTF-8"?>
<?eclipse version="3.2"?>
<plugin>
   <extension
         point="org.eclipse.ui.newWizards">
      <category
            id="customplugin.category.wizards"
            name="%category.name">
      </category>
      <wizard
            category="customplugin.category.wizards"
            class="customplugin.wizards.CustomProjectNewWizard"
            finalPerspective="customplugin.perspective"
            icon="icons/project-folder.png"
            id="customplugin.wizard.new.custom"
            name="%wizard.name">
      </wizard>
      <wizard
            category="customplugin.category.wizards"
            class="customplugin.wizards.CustomProjectNewSchemaFile"
            descriptionImage="icons/schema-file_32x32.png"
            icon="icons/schema-file_16x16.png"
            id="customplugin.wizard.file.schema"
            name="%wizard.name.schema">
      </wizard>
      <wizard
            category="customplugin.category.wizards"
            class="customplugin.wizards.CustomProjectNewDeploymentFile"
            descriptionImage="icons/deployment-file_32x32.png"
            icon="icons/deployment-file_16x16.png"
            id="customplugin.wizard.file.deployment"
            name="%wizard.name.deployment">
      </wizard>
   </extension>
   <extension
         point="org.eclipse.ui.perspectives">
      <perspective
            class="customplugin.perspectives.Perspective"
            icon="icons/perspective.png"
            id="customplugin.perspective"
            name="%perspective.name">
      </perspective>
   </extension>
   <extension
         point="org.eclipse.ui.perspectiveExtensions">
      <perspectiveExtension
            targetID="customplugin.perspective">
         <view
               id="customnavigator.navigator"
               minimized="false"
               ratio=".25"
               relationship="left"
               relative="org.eclipse.ui.editorss">
         </view>
      </perspectiveExtension>
   </extension>
   <extension
         id="customplugin.projectNature"
         point="org.eclipse.core.resources.natures">
      <runtime>
         <run
               class="customplugin.natures.ProjectNature">
         </run>
      </runtime>
   </extension>
   <extension
         point="org.eclipse.ui.ide.projectNatureImages">
      <image
            icon="icons/project-folder.png"
            id="customplugin.natureImage"
            natureId="customplugin.projectNature">
      </image>
   </extension>
   <extension
         id="customplugin.contenttype"
         point="org.eclipse.core.contenttype.contentTypes">
      <content-type
            base-type="org.eclipse.core.runtime.xml"
            file-extensions="xml"
            id="customplugin.contenttype.schema"
            name="%content-type.name.schema"
            priority="normal">
         <describer
               class="org.eclipse.core.runtime.content.XMLRootElementContentDescriber2">
            <parameter
                  name="element"
                  value="hc-schema">
            </parameter>
         </describer>
      </content-type>
      <content-type
            base-type="org.eclipse.core.runtime.xml"
            file-extensions="xml"
            id="customplugin.contenttype.deployment"
            name="%content-type.name.deployment"
            priority="normal">
         <describer
               class="org.eclipse.core.runtime.content.XMLRootElementContentDescriber2">
            <parameter
                  name="element"
                  value="hc-deployment">
            </parameter>
         </describer>
      </content-type>
   </extension>
   <extension
         point="org.eclipse.ui.perspectiveExtensions">
      <perspectiveExtension
            targetID="customplugin.perspective">
         <newWizardShortcut
               id="customplugin.wizard.new.custom">
         </newWizardShortcut>
         <newWizardShortcut
               id="customplugin.wizard.file.schema">
         </newWizardShortcut>
         <newWizardShortcut
               id="customplugin.wizard.file.deployment">
         </newWizardShortcut>
      </perspectiveExtension>
   </extension>
   <extension
         point="org.eclipse.ui.menus">
      <menuContribution
            locationURI="toolbar:org.eclipse.ui.main.toolbar">
         <toolbar
               id="customplugin.toolbar">
            <command
                  commandId="org.eclipse.ui.newWizard"
                  icon="icons/project-folder.png"
                  label="%customproject.label"
                  style="push"
                  tooltip="%customproject.tooltip">
               <parameter
                     name="newWizardId"
                     value="customplugin.wizard.new.custom">
               </parameter>
               <visibleWhen
                     checkEnabled="false">
                  <with
                        variable="activeWorkbenchWindow.activePerspective">
                     <equals
                           value="customplugin.perspective">
                     </equals>
                  </with>
               </visibleWhen>
            </command>
            <command
                  commandId="org.eclipse.ui.newWizard"
                  icon="icons/schema-file_16x16.png"
                  label="%schema.label"
                  style="push"
                  tooltip="%schema.tooltip">
               <parameter
                     name="newWizardId"
                     value="customplugin.wizard.file.schema">
               </parameter>
               <visibleWhen
                     checkEnabled="false">
                  <with
                        variable="activeWorkbenchWindow.activePerspective">
                     <equals
                           value="customplugin.perspective">
                     </equals>
                  </with>
               </visibleWhen>
            </command>
            <command
                  commandId="org.eclipse.ui.newWizard"
                  icon="icons/deployment-file_16x16.png"
                  label="%deployment.label"
                  style="push"
                  tooltip="%deployment.tooltip">
               <parameter
                     name="newWizardId"
                     value="customplugin.wizard.file.deployment">
               </parameter>
               <visibleWhen
                     checkEnabled="false">
                  <with
                        variable="activeWorkbenchWindow.activePerspective">
                     <equals
                           value="customplugin.perspective">
                     </equals>
                  </with>
               </visibleWhen>
            </command>
         </toolbar>
      </menuContribution>
   </extension>

</plugin>

OSGI-INF/I10n/bundle.properties

#Properties file for customplugin
Bundle-Name = Customplugin Plug-in
category.name = Custom Wizards
wizard.name = Custom Project
perspective.name = Custom Plug-in Perspective
content-type.name.schema = Hidden Clause Schema Definition
content-type.name.deployment = Hidden Clause Deployment Definition
wizard.name.schema = Schema File
wizard.name.deployment = Deployment File

customproject.label = New Custom Project
customproject.tooltip = New Custom Project
schema.label = New Schema File
schema.tooltip = New Schema File
deployment.label = New Deployment File
deployment.tooltip = New Deployment File

Writing an Eclipse Plug-in (Part 15): Custom Project: Customizing the Perspective Menus (Main menu)

December 19, 2009 4 comments

Ah! Nothing like returning to the scene of the crime.

When we were last at the crime scene we were displaying projects in the Custom Navigator in various states of openness and closedness. What could possibly be next? Well, there are a few choices:

  • Customize the Custom Perspective so our current capabilities are available in the main workspace menu, toolbar and Customize Perspective window.
  • Add navigator popup menus to do things like New, Copy and Properties
  • Display information in the project structure

Even though I expect to create a Form-based editor to hide the ugliness of an XML file that is not necessarily the task of greatest import. In this post I am going to show how to add menu items to our Custom Perspective; we will customize the Custom Navigator popup menu in a future post.

We should always be implementing with the end in mind as a way of keeping extraneous features to a minimum anyway. At least that’s my story.

What (are we doing?)

There are about 7 ways to do almost anything in Eclipse. For example, if you want to open the New Wizard you could go about doing that in the following ways:

  1. Ctrl+N
  2. Main menu: File –> New
  3. Shift+Alt+N – opens the popup menu; select New
  4. Right click on a Project and select New
  5. Right click on a Folder and select New
  6. Toolbar: New button
  7. Toolbar: Java Class button: New: JUnit Test, Class, Interface, Enum, Annotation

And those were just the ones I thought of off the top of my head (okay, so maybe I tried them all first…).

So, in order to compete with all of the other plug-ins out there a plug-in developer has to make sure there are at least a minimum of ways to activate their plug-in: CRUD functionality (New, Open, Save, Delete), opening editor(s) and view(s), open the Properties window, etc.

The good news: Ctrl+N and Shift+Alt+N open the New Wizard window in every case (unless you change the key bindings) so we can safely ignore them.

The bad news: we only have a New Wizard for Custom Projects and two file types. This means that the only way to create a custom resource is either from the main menu (File –> New –> Other), Ctrl+N, or Shift+Alt+N. Since all three will activate the default New Wizard we have not gained anything.

The lesson to learn here is when you add something to the New Wizard your task list should include updating your perspective to support the:

  • Main Menu File menu
  • Toolbar
  • Customize Perspective window

Notice how the only thing this will do is make your existing behavior available in more places. Not a bad thing, just kinda extraneous; convenient for the user, feels like busy work for the developer.

You could also decide to add your GUI functionality to all of the perspectives, but beware: each perspective is specific to the task at hand. Adding the ability to do random things in arbitrary perspectives is bad form. Add functionality to specific perspectives as appropriate (what that means will vary with the capability you are implementing). Adding plugin.xml to a COBOL project doesn’t really mean anything. The road to menu pollution is paved with good intentions. Don’t be afraid to create custom perspectives where you can just go to town adding whatever you want with impunity.

So the tasks for the next few blogs are to add:

  • In the main menu: add Custom Projects, Schema, and Deployment files to File –> New
  • In the Toolbar: add a toolbar group for the above 3 items
  • In Customize Perspective: add the ability to enable/disable all of the above

In the Customize Perspective window adding the ability to enable/disable the above capabilities means:

  • Toolbar Visibility: Custom Project Element Creation (enable by default)
    • Custom Project
    • Schema File
    • Deployment File
  • Menu Visibility: File –> New, (already available, enable by default)
    • Custom Project
    • Schema File
    • Deployment File
  • Command Group Availability: Custom Project Element Creation (enable by default)
  • Shortcuts (affects Menu Visibility; enable by default)
    • New
      • Custom Project
      • Schema File
      • Deployment File
    • Open Perspective (Affects main menu Windows –> Open Perspective)
      • Resource (available, but not enabled)
    • Show View (Affects main menu Windows –> Open View)
      • Custom Plug-in Navigator (available, but not enabled)

How (are we doing it?)

In the main menu: add Custom Projects, Schema, and Deployment files to File –> New

Adding New Wizard entries onto the menu menu is done completely by configuration (my favorite).

  1. Open up plugin.xml for customplugin
  2. Go to Extensions–> Add –> perspectiveExtension and click Finish (yes, you could skip this step and use the existing perspectiveExtension entry)
  3. Change
    • targetID: *

    to

    • targetID: customplugin.perspective
  4. Right click on customplugin.perspective (perspectiveExtension) –> new –> newWizardShortcut
  5. Select newWizardShortcut and enter:
    • ID: customplugin.wizard.new.custom
    • The above is the id of the New Custom Project Wizard entry under org.eclipse.ui,newWizards –> Custom Project (wizard)
  6. Perform steps 4 and 5 for the Schema File (wizard) and Deployment File (wizard)
  7. Save plugin.xml

To make them appear in all of the perspectives change (do not try this at home. I am a trained professional):

  • Extensions –> perspectiveExtension –> targetID: customplugin.perspective

to

  • Extensions –> perspectiveExtension –> targetID: *

Remember, only you can prevent menu pollution.

As a wonderful side-effect the Customize Perspective window has the three New wizard entries entered automatically. Start the runtime workbench, open the Customize Perspective window (Windows –> Customize Perspective), select Menu Visibility and open File –> New.

In addition select the Shortcuts tab of the Customize Perspective window and see that for Submenu New the Shortcut Category has Custom Wizards selected and the three wizards are already checked.

The Toolbar tab and the Command Groups Availability tab are both devoid of entries for our Custom Project. Are we going to take care of that now? Well…no. Next time. Really. I know you’re disappointed, but if you push me I’ll make sure you get a lump of coal.

What Just Happened?

Configuration. Nothing like it for tedious tasks.

How much code did we write: none. It is going to be a good holiday.

Well, that’s it for this entry. It is Sunday, the holidays are getting closer and I was lucky to get this post out.

Next time: Adding the New Wizard functionality to the Toolbar. Maybe. If I get a Sega R-360.

Yuletide Felicitations!

Writing an Eclipse Plug-in (Part 14): Common Navigator: Refactoring the Children

December 13, 2009 6 comments

Well, it is late on a Sunday afternoon and I really want to refactor the children of the navigator. There are about 22 warnings and I hate warnings (almost as much as I hate errors).

This is probably going to be a short posting with lots of code. Go with it.

What (are we doing?)

The tasks are as usual:

  • Refactor common code
  • Add annotations as needed
  • Fix build path problems
  • Externalize strings

Let’s refactor.

Refactor common code

The first thing we can safely say about the various node wrappers is that they all contain a reference to:

  • Their parent
  • Any children
  • An image

That means that at the very least we can centralize the defintion of those three references. That means, using the refactor capability of Eclipse I pushed the following methods into a new parent class named CustomProjectElement:

  • getText()
  • getImage()
  • getParent()
  • getProject()

CustomProjectElement.java

package customnavigator.navigator;

import org.eclipse.core.resources.IProject;
import org.eclipse.swt.graphics.Image;

import customnavigator.Activator;

public abstract class CustomProjectElement implements ICustomProjectElement {

    private Image _image;
    private String _name;
    private String _imagePath;
    private ICustomProjectElement _parent;
    private ICustomProjectElement[] _children;

    public CustomProjectElement(ICustomProjectElement parent, String name, String imagePath) {
        _parent = parent;
        _name = name;
        _imagePath = imagePath;
    }

    @Override
    public String getText() {
        return _name;
    }

    @Override
    public Image getImage() {
        if (_image == null) {
            _image = Activator.getImage(_imagePath);
        }
    
        return _image;
    }

    @Override
    public ICustomProjectElement getParent() {
        return _parent;
    }

    @Override
    public IProject getProject() {
        return getParent().getProject();
    }

    @Override
    public ICustomProjectElement[] getChildren() {
        if (_children == null) {
            _children = initializeChildren(getProject());
        }
        // else we have already initialized them

        return _children;
    }

    @Override
    public boolean hasChildren() {
        if (_children == null) {
            _children = initializeChildren(getProject());
        }
        // else we have already initialized them
        return _children.length > 0;
    }

    protected abstract ICustomProjectElement[] initializeChildren(IProject project); 
}

Centralizing the methods actually cut the warnings down to 12 and each class is significantly smaller having passed the trivial responsibility of the getter methods to the parent class.

Of course, now that we have refactored all that code we have to run some tests to show that the system still works, but we never wrote any tests for the wrapper classes.

What to do? What to do?

Oh, yeah. The wrapper classes come from the ContentProvider. We have plenty of tests for that one.

Run the customnavigator.test.

Oh oh! Three of the tests fail! Another perfect day! Time to debug. The first method to check: testGetChildrenForIWorkspaceRootWithOneCustomProject().

Ah! I needed to have another call to project.getName(). How interesting is that? The test code was wrong! I hate when that happens.

    @Test
    public void testGetChildrenForIWorkspaceRootWithOneCustomProject() throws CoreException {
        IProject [] projects = new IProject[1];
        IProject project = EasyMock.createStrictMock(IProject.class);
        
        projects[0] = project;
        
        IWorkspaceRoot workspaceRoot = EasyMock.createStrictMock(IWorkspaceRoot.class);
        workspaceRoot.getProjects();
        EasyMock.expectLastCall().andReturn(projects);
        
        String projectName = "custom project"; //$NON-NLS-1$
        project.getName();
        EasyMock.expectLastCall().andReturn(projectName);
        
        project.getNature(CUSTOMPLUGIN_PROJECT_NATURE);
        EasyMock.expectLastCall().andReturn(EasyMock.createMock(IProjectNature.class));

        project.getName();
        EasyMock.expectLastCall().andReturn(projectName);

        project.getName();
        EasyMock.expectLastCall().andReturn(projectName);
        
        EasyMock.replay(workspaceRoot, project);
        
        Object [] actual = _contentProvider.getChildren(workspaceRoot);
        Assert.assertNotNull(actual);
        Assert.assertTrue(actual.length == 1);
        Assert.assertEquals(project, ((CustomProjectParent)actual[0]).getProject());
        
        EasyMock.verify(workspaceRoot, project);
    }

Good. A green bar again.

Add annotations as needed

Already added as I refactored to a new parent class. Code below.

Fix build path problems

Open build.properties. There is a yellow mark next to bin.includes; press Ctrl+1 and select Add OSGI-INF/ to bin.includes Build Entry. Add it, please. That actually takes care of two warnings. Something about killing two warnings with one quick fix.

Open MANIFEST.MF. There is a yellow mark next to the Export-Package entry. Press Ctrl+1 and select Add Missing Packages. For some reason the Quick Fix did not work quite right so the Export-Package entry should look like this:

Export-Package: customnavigator.navigator,
 customnavigator.test,
 org.easymock,
 org.easymock.internal,
 org.easymock.internal.matchers

Externalize strings

Opening the Externalize Wizard on each of the node wrappers allowed me to externalize the image path strings. Now they are independent of the code in case we need to move them. Not likely, but what the hell.

The last 6 warnings need the following fixed:

  • Potential null pointer access (which we can’t control since Eclipse calls the methods) – add @SuppressWarning(null) to ProjectNature
  • Externalize a label in plugin.xml – Ctrl+1 at the offending line automatically takes care of that
  • CoreException is not thrown in the methods we overrode in ProjectNature – add @SuppressWarnings(“unused”) to the three offending methods (two in CustomNature and one in CustomProjectNewWizard)

What Just Happened?

Lots of boring stuff was just done, but it was all necessary. The code is below.

Woo hoo. I think I will leave the cat alone in the box until next time.

Code

package customnavigator.navigator;

import org.eclipse.core.resources.IProject;
import org.eclipse.swt.graphics.Image;

import customnavigator.Activator;

public abstract class CustomProjectElement implements ICustomProjectElement {

    private Image _image;
    private String _name;
    private String _imagePath;
    private ICustomProjectElement _parent;
    private ICustomProjectElement[] _children;

    public CustomProjectElement(ICustomProjectElement parent, String name, String imagePath) {
        _parent = parent;
        _name = name;
        _imagePath = imagePath;
    }

    @Override
    public String getText() {
        return _name;
    }

    @Override
    public Image getImage() {
        if (_image == null) {
            _image = Activator.getImage(_imagePath);
        }
    
        return _image;
    }

    @Override
    public ICustomProjectElement getParent() {
        return _parent;
    }

    @Override
    public IProject getProject() {
        return getParent().getProject();
    }

    @Override
    public ICustomProjectElement[] getChildren() {
        if (_children == null) {
            _children = initializeChildren(getProject());
        }
        // else we have already initialized them

        return _children;
    }

    @Override
    public boolean hasChildren() {
        if (_children == null) {
            _children = initializeChildren(getProject());
        }
        // else we have already initialized them
        return _children.length > 0;
    }

    protected abstract ICustomProjectElement[] initializeChildren(IProject project); 
}
/**
 * Coder beware: this code is not warranted to do anything.
 * Copyright Oct 17, 2009 Carlos Valcarcel
 */
package customnavigator.navigator;

import org.eclipse.core.resources.IProject;


/**
 * @author carlos
 */
public class CustomProjectParent extends CustomProjectElement {

    private IProject _project;

    public CustomProjectParent(IProject iProject) {
        super(null, iProject.getName(), Messages.CustomProjectParent_Project_Folder);
        
        _project = iProject;
    }

    @Override
    public IProject getProject() {
        return _project;
    }

    @Override
    protected ICustomProjectElement[] initializeChildren(IProject project) {
        ICustomProjectElement[] children = {
                new CustomProjectSchema(this),
                new CustomProjectStoredProcedures(this)
        };

        return children;
    }

}
/**
 * Coder beware: this code is not warranted to do anything.
 *
 * Copyright Oct 18, 2009 Carlos Valcarcel
 */
package customnavigator.navigator;

import org.eclipse.core.resources.IProject;


/**
 * @author carlos
 *
 */
public class CustomProjectSchema extends CustomProjectElement {

    public static final String NAME = "Schema"; //$NON-NLS-1$

    public CustomProjectSchema(ICustomProjectElement parent) {
        super(parent, NAME, Messages.CustomProjectSchema_Project_Schema);
    }

    @Override
    protected ICustomProjectElement[] initializeChildren(IProject iProject) {
        ICustomProjectElement[] children = new ICustomProjectElement [] {
                new CustomProjectSchemaTables(this),
                new CustomProjectSchemaViews(this),
                new CustomProjectSchemaFilters(this)
        };
        
        return children;
    }

}
/**
 * Coder beware: this code is not warranted to do anything.
 *
 * Copyright Oct 18, 2009 Carlos Valcarcel
 */
package customnavigator.navigator;

import org.eclipse.core.resources.IProject;

/**
 * @author carlos
 *
 */
public class CustomProjectSchemaFilters extends CustomProjectElement {

    public static final String NAME = "Filters"; //$NON-NLS-1$

    public CustomProjectSchemaFilters(ICustomProjectElement parent) {
        super(parent, NAME, Messages.CustomProjectSchemaFilters_Project_Schema_Filters);
    }

    /* (non-Javadoc)
     * @see customnavigator.navigator.ICustomProjectElement#getChildren()
     */
    @Override
    protected ICustomProjectElement[] initializeChildren(IProject iProject) {
        ICustomProjectElement[] children = new ICustomProjectElement [0];
        
        return children;
    }
}
/**
 * Coder beware: this code is not warranted to do anything.
 *
 * Copyright Oct 18, 2009 Carlos Valcarcel
 */
package customnavigator.navigator;

import org.eclipse.core.resources.IProject;

/**
 * @author carlos
 *
 */
public class CustomProjectSchemaTables extends CustomProjectElement {

    public static final String NAME = "Tables"; //$NON-NLS-1$

    public CustomProjectSchemaTables(ICustomProjectElement parent) {
        super(parent, NAME, Messages.CustomProjectSchemaTables_Project_Schema_Tables);
    }

    @Override
    protected ICustomProjectElement[] initializeChildren(IProject iProject) {
        ICustomProjectElement[] children = new ICustomProjectElement [0];
        
        return children;
    }
}
/**
 * Coder beware: this code is not warranted to do anything.
 *
 * Copyright Oct 18, 2009 Carlos Valcarcel
 */
package customnavigator.navigator;

import org.eclipse.core.resources.IProject;

/**
 * @author carlos
 *
 */
public class CustomProjectSchemaViews extends CustomProjectElement {

    public static final String NAME = "Views"; //$NON-NLS-1$

    public CustomProjectSchemaViews(ICustomProjectElement parent) {
        super(parent, NAME, Messages.CustomProjectSchemaViews_Project_Schema_Views);
    }

    @Override
    protected ICustomProjectElement[] initializeChildren(IProject iProject) {
        ICustomProjectElement[] children = new ICustomProjectElement [0];
        
        return children;
    }
}
/**
 * Coder beware: this code is not warranted to do anything.
 *
 * Copyright Oct 18, 2009 Carlos Valcarcel
 */
package customnavigator.navigator;

import org.eclipse.core.resources.IProject;

/**
 * @author carlos
 *
 */
public class CustomProjectStoredProcedures extends CustomProjectElement {

    public static final String NAME = "Stored Procedures"; //$NON-NLS-1$

    public CustomProjectStoredProcedures(ICustomProjectElement parent) {
        super(parent, NAME, Messages.CustomProjectStoredProcedures_Project_Stored_Procedures);
    }

    @Override
    protected ICustomProjectElement[] initializeChildren(IProject iProject) {
        ICustomProjectElement[] children = new ICustomProjectElement [0];
        
        return children;
    }
}
/**
 * Coder beware: this code is not warranted to do anything.
 * Copyright Oct 17, 2009 Carlos Valcarcel
 */
package customnavigator.navigator;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.eclipse.core.resources.IProject;
import org.eclipse.core.resources.IResourceChangeEvent;
import org.eclipse.core.resources.IResourceChangeListener;
import org.eclipse.core.resources.IWorkspaceRoot;
import org.eclipse.core.resources.ResourcesPlugin;
import org.eclipse.core.runtime.CoreException;
import org.eclipse.jface.viewers.ITreeContentProvider;
import org.eclipse.jface.viewers.TreePath;
import org.eclipse.jface.viewers.TreeViewer;
import org.eclipse.jface.viewers.Viewer;

import customplugin.natures.ProjectNature;

/**
 * @author carlos
 */
public class ContentProvider implements ITreeContentProvider, IResourceChangeListener {

    private static final Object[]   NO_CHILDREN = {};
    private Map<String, Object> _wrapperCache = new HashMap<String, Object>();
    private Viewer _viewer;

    public ContentProvider() {
        ResourcesPlugin.getWorkspace().addResourceChangeListener(this, IResourceChangeEvent.POST_CHANGE);
    }

    /*
     * (non-Javadoc)
     * @see
     * org.eclipse.jface.viewers.ITreeContentProvider#getChildren(java.lang.Object)
     */
    @Override
    public Object[] getChildren(Object parentElement) {
        Object[] children = null;
        if (IWorkspaceRoot.class.isInstance(parentElement)) {
            IProject[] projects = ((IWorkspaceRoot)parentElement).getProjects(); 
            children = createCustomProjectParents(projects);
        } else if (ICustomProjectElement.class.isInstance(parentElement)) {
            children = ((ICustomProjectElement) parentElement).getChildren();
        } else {
            children = NO_CHILDREN;
        }

        return children;
    }

    /*
     * (non-Javadoc)
     * @see
     * org.eclipse.jface.viewers.ITreeContentProvider#getParent(java.lang.Object)
     */
    @SuppressWarnings("null")
    @Override
    public Object getParent(Object element) {
        Object parent = null;
            
        if (IProject.class.isInstance(element)) {
            parent = ((IProject)element).getWorkspace().getRoot();
        } else if (ICustomProjectElement.class.isInstance(element)) {
            parent = ((ICustomProjectElement)element).getParent();
        } // else parent = null if IWorkspaceRoot or anything else
        
        return parent;
    }

    /*
     * (non-Javadoc)
     * @see
     * org.eclipse.jface.viewers.ITreeContentProvider#hasChildren(java.lang.Object)
     */
    @Override
    public boolean hasChildren(Object element) {
        boolean hasChildren = false;

        if (IWorkspaceRoot.class.isInstance(element)) {
            hasChildren = ((IWorkspaceRoot)element).getProjects().length > 0;
        } else if (ICustomProjectElement.class.isInstance(element)) {
            hasChildren = ((ICustomProjectElement)element).hasChildren();
        }
        // else it is not one of these so return false
        
        return hasChildren;
    }

    /*
     * (non-Javadoc)
     * @see
     * org.eclipse.jface.viewers.IStructuredContentProvider#getElements(java.lang.Object)
     */
    @Override
    public Object[] getElements(Object inputElement) {
        // This is the same as getChildren() so we will call that instead
        return getChildren(inputElement);
    }

    /*
     * (non-Javadoc)
     * @see org.eclipse.jface.viewers.IContentProvider#dispose()
     */
    @Override
    public void dispose() {
        ResourcesPlugin.getWorkspace().removeResourceChangeListener(this);
    }

    /*
     * (non-Javadoc)
     * @see
     * org.eclipse.jface.viewers.IContentProvider#inputChanged(org.eclipse.jface.viewers.Viewer, java.lang.Object, java.lang.Object)
     */
    @Override
    public void inputChanged(Viewer viewer, Object oldInput, Object newInput) {
        _viewer = viewer;
    }

    private int _count = 1;
    
    @Override
    public void resourceChanged(IResourceChangeEvent event) {
        TreeViewer viewer = (TreeViewer) _viewer;
        TreePath[] treePaths = viewer.getExpandedTreePaths();
        viewer.refresh();
        viewer.setExpandedTreePaths(treePaths); 
        System.out.println("ContentProvider.resourceChanged: completed refresh() and setExpandedXXX()"); //$NON-NLS-1$
    }

    private Object createCustomProjectParent(IProject parentElement) {

        Object result = null;
        try {
            if (parentElement.getNature(ProjectNature.NATURE_ID) != null) {
                result = new CustomProjectParent(parentElement);
            }
        } catch (CoreException e) {
            // Go to the next IProject
        }

        return result;
    }

    private Object[] createCustomProjectParents(IProject[] projects) {
        Object[] result = null;
        
        List<Object> list = new ArrayList<Object>();
        for (int i = 0; i < projects.length; i++) {
            Object customProjectParent = _wrapperCache.get(projects[i].getName()); 
            if (customProjectParent == null) {
                customProjectParent = createCustomProjectParent(projects[i]);
                if (customProjectParent != null) {
                    _wrapperCache.put(projects[i].getName(), customProjectParent);
                }
            }

            if (customProjectParent != null) {
                list.add(customProjectParent);
            } // else ignore the project
        }
        
        result = new Object[list.size()];
        list.toArray(result);
        
        return result;
    }

}
package customplugin.natures;

import org.eclipse.core.resources.IProject;
import org.eclipse.core.resources.IProjectNature;
import org.eclipse.core.runtime.CoreException;

public class ProjectNature implements IProjectNature {

    public static final String NATURE_ID = "customplugin.projectNature"; //$NON-NLS-1$

    @SuppressWarnings("unused")
    @Override
    public void configure() throws CoreException {
        // TODO Auto-generated method stub

    }

    @SuppressWarnings("unused")
    @Override
    public void deconfigure() throws CoreException {
        // TODO Auto-generated method stub

    }

    @Override
    public IProject getProject() {
        // TODO Auto-generated method stub
        return null;
    }

    @Override
    public void setProject(IProject project) {
        // TODO Auto-generated method stub

    }

}
package customplugin.wizards;

import java.net.URI;

import org.eclipse.core.runtime.CoreException;
import org.eclipse.core.runtime.IConfigurationElement;
import org.eclipse.core.runtime.IExecutableExtension;
import org.eclipse.jface.viewers.IStructuredSelection;
import org.eclipse.jface.wizard.Wizard;
import org.eclipse.ui.INewWizard;
import org.eclipse.ui.IWorkbench;
import org.eclipse.ui.dialogs.WizardNewProjectCreationPage;
import org.eclipse.ui.wizards.newresource.BasicNewProjectResourceWizard;

import customplugin.projects.CustomProjectSupport;

public class CustomProjectNewWizard extends Wizard implements INewWizard, IExecutableExtension {

    private static final String WIZARD_NAME = "New Custom Plug-in Project"; //$NON-NLS-1$
    private static final String PAGE_NAME = "Custom Plug-in Project Wizard"; //$NON-NLS-1$
    private WizardNewProjectCreationPage _pageOne;
    private IConfigurationElement _configurationElement;

    public CustomProjectNewWizard() {
        setWindowTitle(WIZARD_NAME);
    }

    @Override
    public boolean performFinish() {
        String name = _pageOne.getProjectName();
        URI location = null;
        if (!_pageOne.useDefaults()) {
            location = _pageOne.getLocationURI();
        } // else location == null
        
        CustomProjectSupport.createProject(name, location);
        BasicNewProjectResourceWizard.updatePerspective(_configurationElement);

        return true;
    }

    @Override
    public void addPages() {
        super.addPages();
        _pageOne = new WizardNewProjectCreationPage(PAGE_NAME);
        _pageOne.setTitle(NewWizardMessages.CustomProjectNewWizard_Custom_Plugin_Project);
        _pageOne.setDescription(NewWizardMessages.CustomProjectNewWizard_Create_something_custom);

        addPage(_pageOne);
    }

    @Override
    public void init(IWorkbench workbench, IStructuredSelection selection) {
        // TODO Auto-generated method stub

    }

    @SuppressWarnings("unused")
    @Override
    public void setInitializationData(IConfigurationElement config, String propertyName, Object data) throws CoreException {
        _configurationElement = config;
    }

}

plugin.xml

<?xml version="1.0" encoding="UTF-8"?>
<?eclipse version="3.2"?>
<plugin>
   <extension
         point="org.eclipse.ui.newWizards">
      <category
            id="customplugin.category.wizards"
            name="%category.name">
      </category>
      <wizard
            category="customplugin.category.wizards"
            class="customplugin.wizards.CustomProjectNewWizard"
            finalPerspective="customplugin.perspective"
            icon="icons/project-folder.png"
            id="customplugin.wizard.new.custom"
            name="%wizard.name">
      </wizard>
      <wizard
            category="customplugin.category.wizards"
            class="customplugin.wizards.CustomProjectNewSchemaFile"
            descriptionImage="icons/schema-file_32x32.png"
            icon="icons/schema-file_16x16.png"
            id="customplugin.wizard.file.schema"
            name="%wizard.name.schema">
      </wizard>
      <wizard
            category="customplugin.category.wizards"
            class="customplugin.wizards.CustomProjectNewDeploymentFile"
            descriptionImage="icons/deployment-file_32x32.png"
            icon="icons/deployment-file_16x16.png"
            id="customplugin.wizard.file.deployment"
            name="%wizard.name.deployment">
      </wizard>
   </extension>
   <extension
         point="org.eclipse.ui.perspectives">
      <perspective
            class="customplugin.perspectives.Perspective"
            icon="icons/perspective.png"
            id="customplugin.perspective"
            name="%perspective.name">
      </perspective>
   </extension>
   <extension
         point="org.eclipse.ui.perspectiveExtensions">
      <perspectiveExtension
            targetID="customplugin.perspective">
         <view
               id="customnavigator.navigator"
               minimized="false"
               ratio=".25"
               relationship="left"
               relative="org.eclipse.ui.editorss">
         </view>
      </perspectiveExtension>
   </extension>
   <extension
         id="customplugin.projectNature"
         point="org.eclipse.core.resources.natures">
      <runtime>
         <run
               class="customplugin.natures.ProjectNature">
         </run>
      </runtime>
   </extension>
   <extension
         point="org.eclipse.ui.ide.projectNatureImages">
      <image
            icon="icons/project-folder.png"
            id="customplugin.natureImage"
            natureId="customplugin.projectNature">
      </image>
   </extension>
   <extension
         id="customplugin.contenttype"
         point="org.eclipse.core.contenttype.contentTypes">
      <content-type
            base-type="org.eclipse.core.runtime.xml"
            file-extensions="xml"
            id="customplugin.contenttype.schema"
            name="%content-type.name.schema"
            priority="normal">
         <describer
               class="org.eclipse.core.runtime.content.XMLRootElementContentDescriber2">
            <parameter
                  name="element"
                  value="hc-schema">
            </parameter>
         </describer>
      </content-type>
      <content-type
            base-type="org.eclipse.core.runtime.xml"
            file-extensions="xml"
            id="customplugin.contenttype.deployment"
            name="%content-type.name.deployment"
            priority="normal">
         <describer
               class="org.eclipse.core.runtime.content.XMLRootElementContentDescriber2">
            <parameter
                  name="element"
                  value="hc-deployment">
            </parameter>
         </describer>
      </content-type>
   </extension>
   <extension
         point="org.eclipse.ui.menus">
      <menuContribution
            locationURI="popup:org.eclipse.ui.popup.any?after=additions">
         <menu
               label="%menu.label">
            <visibleWhen
                  checkEnabled="true">
            </visibleWhen>
         </menu>
      </menuContribution>
   </extension>

</plugin>

And Now By Popular Demand: jWebUnit

December 13, 2009 1 comment

In the land of TDD things start pretty raw (plain vanilla JUnit) and slowly work their way out. While there are all sorts of areas to test (databases, web services, servlets, EJBs, POJOs and others) the fact is you not only have to be conscious of your testing, but you have to also decide how much testing to do. During the implementation of web applications what you find out is that your tests can do at least one of two things:

  • flow testing
  • integration testing

The thing is you don’t want any one test to do both. You should have one test for flow (unit) testing and another for integration testing.

Are there other test types? Of course (you just have to read JUnit Recipes to figure that out). Being the lazy guy that I am I really only perform functional and integration tests when I write my own code and rely on the customer to decide how far down the rabbit hole they want to go. How can you help them decide how much testing to do? Ask a simple question: how much would they lose if something went wrong? This is a question of risk tolerance. Software responsible for life and death should be tested until the cows come home. Software that is responsible for making/saving money should be tested to the customers limit of legal liability; after that test to what they can afford, but at least now you have a baseline.

But I digress. It must be the squirrels.

There are a lot of frameworks for web app testing and I have arbitrarily chosen jWebUnit as the winner because I am boring and stuck in the past thoughtful and forward-looking and I have just not had time to look at all of the available choices some of which are:

[I remember looking at FitNesse and Selenium and even bought a book on FitNesse, but never got so far as to actually implement anything with it. My loss. It looks really good.]

There are so many things to say about what you should be thinking and how you should be thinking in regards to your tests. My recommendation is that you should use your use cases to guide your integration testing (you are writing use cases, aren’t you?) and just get to work. If you want philosophical guidance (and I am never short of that) you should read as much as you can about TDD and testing at all of the various levels. For example:

[Just as an aside: I love Spring. TDD is so easy with Spring. You test every level of your application (db, POJOs, messaging, web pages) as if they were all POJOs…because they are. If you can use Spring to develop your apps, web or otherwise, please use it. I can’t imagine developing an application without it.]

Enough mayonnaise. What are we doing? How are we doing it? Why did we do it that way?

What (are we doing?)

The Use Case:
Actor: A Visitor (not like from V, but they could use this too)

Scenario 1:
1a: The Visitor, wanting to register for a special Hidden Clause prize, is on the registration page. The registration form has the following fields:

  • Name (no more than 30 characters, no numbers)
  • Password (hidden)

If they user does not enter information in either or both fields display the form again and ask them to enter valid information.

1b: When the visitor has submitted the registration page they will be asked to confirm their information.

1c. When they confirm that the information we have is correct a Success page will reassure them that they will be receiving their HC prize any day now.

Scenario 2:
2a. As above in 1a.

2b. When the visitor has submitted the registration page they will be asked to confirm their information. If the information is incorrect open the registration form again with their current information.

2c. As above in 1c.

Scenario 3:
3a. As above in 1a.

3b. As above in 1b.

3c. The registration process failed due to a system failure. Apologize to the user and ask them to try again later or call in for their prize.

The tests:

  • Scenario 1
    • Good scenario
      1. Check title for login page
      2. Fill in the two fields with valid information
      3. Submit login page
      4. Check title for confirmation
      5. Confirm that the content we entered was accepted
      6. Submit confirmation page
      7. Check title for success page
      8. Confirm success message
    • Error scenario 1 – required fields empty
      1. Go to login page
      2. Submit login page without filling in the fields
      3. Check login page for error message
    • Error scenario 2 – required content is invalid
      1. Go to login page
      2. Fill in the two fields with invalid information
      3. Submit login information
      4. Check login page for error message
    • Error scenario 3 – unknown error occurred on submit
      1. Go to login page
      2. Fill in the two fields with valid information
      3. Submit login information
      4. Check login page for error message
  • Scenario 2
    • Good scenario
      1. Check title for login page
      2. Fill in the two fields with valid information
      3. Submit login page
      4. Check title for confirmation page
      5. Confirm that the content we entered was accepted
      6. Cancel confirmation page
      7. Confirm return to login page
      8. Confirm fields are filled in
    • Error scenario
      • None
  • Scenario 3
    • Good scenario
      1. Go to login page
      2. Fill in the two fields with valid information
      3. Submit login page
      4. Check title for confirmation page
      5. Submit confirmation page
      6. Confirm error page is display with associated message
    • Error scenario
      • None

Things we are not doing:

  • Testing field validation
  • Testing database behavior

It is not that we don’t care (well, I don’t but…), but rather that the code for that behavior should already have been tested. If we are doing integration testing we are just guaranteeing that the behavior we expect actually occurs. If the various tests have been done then we can be sure that things should work. However, things can still fail which is why running integration tests is so important.

The tests above are just what I came up with off the top of my head. Never be afraid to add more tests to your test suite, but remember: don’t test things you don’t have to.

How (are we doing it?)

Software Requirements

  1. Create a Java Project and name if jWebUnit-HiddenClausePrizeTest
  2. Add jWebUnit to the project through the project Preferences window
    • jwebunit-core-2.2.jar
    • jwebunit-htmlunit-plugin-2.2.jar
    • All of the JAR files under $JWEBUNIT/lib
  3. Create a JUnit Test Case
    • JUnit 4
    • Package: com.hiddenclause.jwebunit.example
    • Name: UseCaseRegistrationTest
  4. Scenario 1
    • Implement testScenario1EverythingWorks()
    • Implement testScenario1EmptyInputFields()
    • Implement testScenario1InvalidNameInputNumeric()
    • Implement testScenario1InvalidNameInputLength()
    • Implement testScenario1InvalidPasswordInputNumeric()
    • Implement testScenario1InvalidPasswordInputLength()
    • Implement testScenario1UnknownError()
  5. Scenario 2
    • Implement testScenario2UserReEntersRegistrationInformation()
  6. Scenario 3
    • Implement testScenario3SystemErrorOnConfirm()

I did not list the code above as it is all duplicated below.

Why (did we do it that way?)

With any luck you have already created the Java project with JUnit 4 and the various jWebUnit JAR files. If not, rewind, perform the first few steps of the How section and come back.

In TDD you are supposed to:

  • Write a test and watch it fail
  • Write the code to make the test pass and watch it pass
  • Refactor

In short: lather, rinse, repeat. As you write the tests the implementation code comes to life, you finish your project early and your manager showers you with riches and accolades and all will be right with the world.

Good luck with that last part.

So let’s test Scenario 1 of the use case.

Implement testScenario1EverythingWorks()

We start by writing a minimal setUp() method and a call to WebTester.beginAt() in testScenario1EverythingWorks().

UseCaseRegistrationTest.java

public class UseCaseRegistrationTest {
    private WebTester _webTester;

    @Before
    public void setUp() {
        _webTester = new WebTester();
        _webTester.setTestingEngineKey(TestingEngineRegistry.TESTING_ENGINE_HTMLUNIT);

        _webTester.setBaseUrl("http://localhost:8080/hiddenclause"); //$NON-NLS-1$
    }

    @Test
    public void testScenario1EverythingWorks() {
        _webTester.beginAt("/login.jsp");
    }
}

Run the above and watch it fail. Excellent! Not only does login.jsp not exist neither does the web context hiddenclause. Time to write the code that passes.

  • Create a Dynamic Web Project and name it (wait for it) hiddenclause
  • Assign your Tomcat 6.0 installation as the Target Runtime
  • Create a JSP page and name it login.jsp
  • Right click on hiddenclause –> WebContent –> login.jsp –> Run on Server and click Finish

The Eclipse web browser should open on an empty page. Perfect. Run the jWebUnit test again. The test passes. What? You call that a test? Well, yes; anytime you find bad behavior you are testing your expectations of the system; something goes wrong and you do something to fix it. This is all part of the interactivity of test-driven development. Embrace it.

Quick review: what are we trying to test in the flow for scenario 1:

  1. Check title for login page
  2. Fill in the two fields with valid information
  3. Submit login page
  4. Check title for confirmation page
  5. Confirm that the content we entered was accepted
  6. Submit confirmation page
  7. Check title for success page
  8. Confirm success message

Let’s check for the title:

UseCaseRegistrationTest.java

    @Test
    public void testScenario1EverythingWorks() {
        _webTester.beginAt("/login.jsp");

        _webTester.assertTitleEquals("Welcome! Register here!");
    }

Run the test. Hmm. Lots of red. Hmm. The title doesn’t appear to be in login.jsp. I guess we should put it in.

login.jsp

<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <title>Welcome! Register here!</title>
  </head>
  <body>

  </body>
</html>

Run the test. Should pass.

So the pattern will be: add code to the test until we hit an assertion. At that point:

  1. Run the test
  2. See the assert fail
  3. Update the JSP
  4. Run the test again
  5. See the JSP pass

What’s next:

  1. Fill in the two fields with valid information
  2. Submit login page
  3. Check title for confirmation page

UseCaseRegistrationTest.java

    @Test
    public void testScenario1EverythingWorks() {
        _webTester.beginAt("/login.jsp");

        _webTester.assertTitleEquals("Welcome! Register here!");

        String username = "Paul Revere";
        String password = "OneIfByLand";
        _webTester.setTextField("username", username);
        _webTester.setTextField("password", password);

        _webTester.submit();

        _webTester.assertTitleEquals("Confirm Registration Information");
    }

Run the test. To fix the failure update login.jsp with:

<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <title>Welcome! Register here!</title>
  </head>
  <body>
    <form action="confirmation.jsp">
        <input name="username">
        <input name="password" type="hidden">
        <input type="submit" value="Submit">
    </form>
  </body>
</html>

Create confirmation.jsp and give it the proper title based on the test:

<%@ page language="java" contentType="text/html; charset=UTF-8"
  pageEncoding="UTF-8"%>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <title>Confirm Registration Information</title>
  </head>
  <body>
  </body>
</html>

Run the test; you should pass.

What’s next:

  • Confirm that the content we entered was accepted
  • Submit confirmation page
  • Check title for success page
  • Confirm success message

UseCaseRegistrationTest.java

    @Test
    public void testScenario1EverythingWorks() {
	...

        _webTester.assertTitleEquals("Confirm Registration Information");

        _webTester.assertTextPresent(username);
        _webTester.assertTextPresent(password);

        _webTester.submit();

        _webTester.assertTitleEquals("Success!");
        _webTester.assertTextPresent("Thanks for registering!");
    }

Run the test. Fix the failure by updating confirmation.jsp with:

<%@ page language="java" contentType="text/html; charset=UTF-8"
  pageEncoding="UTF-8"%>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <title>Confirm Registration Information</title>
  </head>
  <body>
    Name: <%=request.getParameter("username") %>
    Password: <%=request.getParameter("password") %>
    <form action="success.jsp">
        <input type="submit" value="Submit">
    </form>
  </body>
</html>

Also create success.jsp and change the title:

<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <title>Success!</title>
  </head>
  <body>
    Thanks for registering!
  </body>
</html>

Run the test; you should pass.

I hope you have been finding the jWebUnit code interesting. It is almost script-like. I find that rather than write down the flow on a white board I can just about type it directly into the test and add the calls to the proper jWebUnit API. Very very cool (that’s two verys).

So let’s agree on a process: from here on down I will list the scenario, you will enter the test code, you will execute the test code, you will update the JSP and/or HTML, and you will run the test again. Agreed?

Okay. Let’s go.

Implement testScenario1EmptyInputFields()

Error scenario 1

  • Go to login page
  • Submit login page without filling in the fields
  • Check login page for error message

UseCaseRegistrationTest.java

    @Test
    public void testScenario1EmptyInputFields() {
        _webTester.beginAt("/login.jsp");

        _webTester.submit();

        _webTester.assertTitleEquals("Welcome! Register here!");
        _webTester.assertTextPresent("All fields are required! Try again! Don't make me go back there!");
    }

login.jsp

<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <title>Welcome! Register here!</title>
  </head>
  <body>
  <%
  String msg = null;
  String submit = request.getParameter("submit");
  if (submit != null && submit.equals("Submit")) {
    String username = request.getParameter("username");
    String password = request.getParameter("password");
    if (username == null
            || username.trim().length() == 0
            || password == null
            || password.trim().length() == 0) {
        msg = "All fields are required! Try again! Don't make me go back there!";
    }

    if (msg == null) {
        request.getRequestDispatcher("confirmation.jsp").forward(request, response);
    } else {
    %>
      <span style="color: red;"><%=msg %></span>
    <%
    }
  }
  %>
    <form action="login.jsp">
        <input name="username">
        <input name="password" type="hidden">
        <input type="submit" value="Submit" name="submit">
    </form>
  </body>
</html>

Implement testScenario1InvalidNameInputNumeric()

Error scenario 2

  • Go to login page
  • Fill in the name field with invalid information
  • Submit page login page
  • Check login page for error message

UseCaseRegistrationTest.java

    @Test
    public void testScenario1InvalidNameInputNumeric() {
        _webTester.beginAt("/login.jsp");

        _webTester.setTextField("username", "thx1138");
        _webTester.setTextField("password", "hi");
        _webTester.submit();

        _webTester.assertTitleEquals("Welcome! Register here!");
        _webTester.assertTextPresent("Yo! The username field can only contain letters!");
    }

login.jsp

<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<%@page import="java.util.regex.Pattern"%>
<%@page import="java.util.regex.Matcher"%>
<%!
 private Pattern _pattern = Pattern.compile("[0-9]");
%>
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <title>Welcome! Register here!</title>
  </head>
  <body>
  <%
  String msg = null;
  String submit = request.getParameter("submit");
  if (submit != null && submit.equals("Submit")) {
    String username = request.getParameter("username");
    String password = request.getParameter("password");
    if (username == null
            || username.trim().length() == 0
            || password == null
            || password.trim().length() == 0) {
        msg = "All fields are required! Try again! Don't make me go back there!";
    } else {
      Matcher matcher = _pattern.matcher(username);
      if (matcher.find()) {
          msg = "Yo! The username field can only contain letters!";
      }
    }

    if (msg == null) {
        request.getRequestDispatcher("confirmation.jsp").forward(request, response);
    } else {
    %>
      <span style="color: red;"><%=msg %></span>
    <%
    }
  }
  %>
    <form action="login.jsp">
        <input name="username">
        <input name="password" type="hidden">
        <input type="submit" value="Submit" name="submit">
    </form>
  </body>
</html>

Implement testScenario1InvalidNameInputLength()

UseCaseRegistrationTest.java

UseCaseRegistrationTest.java

    @Test
    public void testScenario1InvalidNameInputLength() {
        _webTester.beginAt("/login.jsp");

        _webTester.setTextField("username", "abcdefghijklmnopqrstuvwxyzABCDE");
        _webTester.setTextField("password", "hi");
        _webTester.submit();

        _webTester.assertTitleEquals("Welcome! Register here!");
        _webTester.assertTextPresent("Yo! The username can only be 30 letters or less!");
    }

login.jsp

  <%
  String msg = null;
  String submit = request.getParameter("submit");
  if (submit != null && submit.equals("Submit")) {
    String username = request.getParameter("username");
    String password = request.getParameter("password");
    if (username == null
            || username.trim().length() == 0
            || password == null
            || password.trim().length() == 0) {
        msg = "All fields are required! Try again! Don't make me go back there!";
    } else {
      Matcher matcher = _pattern.matcher(username);
      if (matcher.find()) {
          msg = "Yo! The username can only contain letters!";
      }

      if (username.trim().length() > 30) {
          msg = "Yo! The username can only be 30 letters or less!";
      }
    }

    if (msg == null) {
        request.getRequestDispatcher("confirmation.jsp").forward(request, response);
    } else {
    %>
      <span style="color: red;"><%=msg %></span>
    <%
    }
  }
  %>

Implement testScenario1InvalidPasswordInputNumeric()

Error scenario 2

  • Go to login page
  • Fill in the password fields with invalid information
  • Submit login page
  • Check login page for error message

UseCaseRegistrationTest.java

    /*
     * The code for these methods is the same. Refactored them.
     */
    @Test
    public void testScenario1InvalidNameInputNumeric() {
        assertNumericField("thx1138", "hi", "Yo! The username can only contain letters!");
    }

    @Test
    public void testScenario1InvalidPasswordInputNumeric() {
        assertNumericField("Paul Revere", "thx1138", "Yo! The password can only contain letters!");
    }

    private void assertNumericField(String username, String password,
                                    String errMsg) {
        _webTester.beginAt("/login.jsp");

        _webTester.setTextField("username", username);
        _webTester.setTextField("password", password);
        _webTester.submit();

        _webTester.assertTitleEquals("Welcome! Register here!");
        _webTester.assertTextPresent(errMsg);
    }

login.jsp

  <%
  String msg = null;
  String submit = request.getParameter("submit");
  if (submit != null && submit.equals("Submit")) {
    String username = request.getParameter("username");
    String password = request.getParameter("password");
    if (username == null
            || username.trim().length() == 0
            || password == null
            || password.trim().length() == 0) {
        msg = "All fields are required! Try again! Don't make me go back there!";
    } else {
      Matcher matcher = _pattern.matcher(username);
      if (matcher.find()) {
          msg = "Yo! The username can only contain letters!";
      } else if (username.trim().length() > 30) {
          msg = "Yo! The username can only be 30 letters or less!";
      } else {
          matcher = _pattern.matcher(password);
          if (matcher.find()) {
              msg = "Yo! The password can only contain letters!";
          }
      }
    }

    if (msg == null) {
        request.getRequestDispatcher("confirmation.jsp").forward(request, response);
    } else {
    %>
      <span style="color: red;"><%=msg %></span>
    <%
    }
  }
  %>

Implement testScenario1InvalidPasswordInputLength()

UseCaseRegistrationTest.java

    /*
     * The code for these methods is the same. Refactored them.
     */
    @Test
    public void testScenario1InvalidNameInputLength() {
        assertFieldLengthErrorFound("abcdefghijklmnopqrstuvwxyzABCDE", "hi", "Yo! The username can only be 30 letters or less!");
    }

    @Test
    public void testScenario1InvalidPasswordInputLength() {
        assertFieldLengthErrorFound("Paul Revere", "abcdefghijklmnopqrstuvwxyzABCDE", "Yo! The password can only be 30 letters or less!");
    }

    private void assertFieldLengthErrorFound(String username, String password,
                                             String errMsg) {
        _webTester.beginAt("/login.jsp");

        _webTester.setTextField("username", username);
        _webTester.setTextField("password", password);
        _webTester.submit();

        _webTester.assertTitleEquals("Welcome! Register here!");
        _webTester.assertTextPresent(errMsg);
    }

login.jsp

  <%
  String msg = null;
  String submit = request.getParameter("submit");
  if (submit != null && submit.equals("Submit")) {
    String username = request.getParameter("username");
    String password = request.getParameter("password");
    if (username == null
            || username.trim().length() == 0
            || password == null
            || password.trim().length() == 0) {
        msg = "All fields are required! Try again! Don't make me go back there!";
    } else {
      Matcher matcher = _pattern.matcher(username);
      if (matcher.find()) {
          msg = "Yo! The username can only contain letters!";
      } else if (username.trim().length() > 30) {
          msg = "Yo! The username can only be 30 letters or less!";
      } else {
          matcher = _pattern.matcher(password);
          if (matcher.find()) {
              msg = "Yo! The password can only contain letters!";
          } else if (password.trim().length() > 30) {
              msg = "Yo! The password can only be 30 letters or less!";
          }
      }
    }

    if (msg == null) {
        request.getRequestDispatcher("confirmation.jsp").forward(request, response);
    } else {
    %>
      <span style="color: red;"><%=msg %></span>
    <%
    }
  }
  %>

Implement testScenario1UnknownErrorOnSubmit()

Scenario 1 – Unknown error on submit

  1. Go to login page
  2. Fill in the two fields with valid information
  3. Submit login page
  4. Check login page for error message

UseCaseRegistrationTest.java

    @Test
    public void testScenario1UnknownErrorOnSubmit() {
        _webTester.beginAt("/login.jsp");

        String username = "Force an error";
        String password = "VeryBad";
        _webTester.setTextField("username", username);
        _webTester.setTextField("password", password);

        _webTester.submit();

        _webTester.assertTitleEquals("Welcome! Register here!");
        _webTester.assertTextPresent("I'm sorry, but we seem to have encountered an error. Please try again later or contact Hidden Clause customer support.");
    }

login.jsp

  <%
  String msg = null;
  String submit = request.getParameter("submit");
  String username = request.getParameter("username");
  String password = request.getParameter("password");
  if (submit != null && submit.equals("Submit")) {
    if (username == null
            || username.trim().length() == 0
            || password == null
            || password.trim().length() == 0) {
        msg = "All fields are required! Try again! Don't make me go back there!";
    } else if (username.equals("Force an error")) {
	// This else if would normally not be here. This is here to force an error
	// so we can check if our code can handle it.
        msg = "I'm sorry, but we seem to have encountered an error. Please try again later or contact Hidden Clause customer support.";
    } else {
      Matcher matcher = _pattern.matcher(username);
      if (matcher.find()) {
          msg = "Yo! The username can only contain letters!";
      } else if (username.trim().length() > 30) {
          msg = "Yo! The username can only be 30 letters or less!";
      } else {
          matcher = _pattern.matcher(password);
          if (matcher.find()) {
              msg = "Yo! The password can only contain letters!";
          } else if (password.trim().length() > 30) {
              msg = "Yo! The password can only be 30 letters or less!";
          }
      }
    }

    if (msg == null) {
        request.getRequestDispatcher("confirmation.jsp").forward(request, response);
    } else {
    %>
      <span style="color: red;"><%=msg %></span>
    <%
    }
  }
  %>

Implement testScenario2UserReEntersRegistrationInformation()

Scenario 2
Visitor changes registration information

  • Go to login page
  • Fill in the two fields with valid information
  • Submit login page
  • Check title for confirmation page
  • Cancel confirmation page
  • Confirm return to login page
  • Confirm fields are filled in

UseCaseRegistrationTest.java

    @Test
    public void testScenario2UserReEntersRegistrationInformation() {
        _webTester.beginAt("/login.jsp");

        String username = "Paul Revere";
        String password = "OneIfByLand";
        _webTester.setTextField("username", username);
        _webTester.setTextField("password", password);
        _webTester.submit();

        _webTester.assertTitleEquals("Confirm Registration Information");
        _webTester.submit("edit");

        _webTester.assertTitleEquals("Welcome! Register here!");
        _webTester.assertTextFieldEquals("username", username);
        _webTester.assertTextFieldEquals("password", password);
    }

login.jsp

  <body>
  <%
  String msg = null;
  String submit = request.getParameter("submit");
  String username = request.getParameter("username");
  String password = request.getParameter("password");
  if (submit != null && submit.equals("Submit")) {
    if (username == null
            || username.trim().length() == 0
            || password == null
            || password.trim().length() == 0) {
        msg = "All fields are required! Try again! Don't make me go back there!";
    } else if (username.equals("Force an error")) {
        msg = "I'm sorry, but we seem to have encountered an error. Please try again later or contact Hidden Clause customer support.";
    } else {
      Matcher matcher = _pattern.matcher(username);
      if (matcher.find()) {
          msg = "Yo! The username can only contain letters!";
      } else if (username.trim().length() > 30) {
          msg = "Yo! The username can only be 30 letters or less!";
      } else {
          matcher = _pattern.matcher(password);
          if (matcher.find()) {
              msg = "Yo! The password can only contain letters!";
          } else if (password.trim().length() > 30) {
              msg = "Yo! The password can only be 30 letters or less!";
          }
      }
    }

    if (msg == null) {
        request.getRequestDispatcher("confirmation.jsp").forward(request, response);
    } else {
    %>
      <span style="color: red;"><%=msg %></span>
    <%
    }
  }
  %>
    <form action="login.jsp">
        <input name="username" value='<%=username != null ? username : "" %>'>
        <input name="password" type="hidden" value='<%= password != null ? password : "" %>'>
        <input type="submit" value="Submit" name="submit">
    </form>
  </body>

confirmation.jsp

  <body>
    <%
    String username = request.getParameter("username");
    String password = request.getParameter("password");
    %>
    Name: <%= username %>
    Password: <%= password %>
    <form action="success.jsp">
      <input type="submit" value="Submit">
    </form>

    <form action="login.jsp?">
      <input type="hidden" name="username" value="<%=username %>">
      <input type="hidden" name="password" value="<%=password %>">
      <input type="submit" name="edit" value="Edit">
    </form>
  </body>

Implement testScenario3SystemErrorOnConfirm()

Scenario on system error

  • Go to login page
  • Fill in the two fields with valid information
  • Submit login page
  • Check title for confirmation page
  • Submit confirmation page
  • Confirm error page is display with associated message

UseCaseRegistrationTest.java

    @Test
    public void testScenario3SystemErrorOnConfirm() {
        _webTester.beginAt("/login.jsp");

        String username = "Confirmation error";
        String password = "OneIfByLand";
        _webTester.setTextField("username", username);
        _webTester.setTextField("password", password);
        _webTester.submit();

        _webTester.assertTitleEquals("Confirm Registration Information");
        _webTester.submit();

        assertLoginPageWithErrorMessage("I'm sorry, but we seem to have encountered an error. Please try again later or contact Hidden Clause customer support.");
    }

login.jsp

  <%
  String msg = null;
  String submit = request.getParameter("submit");
  String username = request.getParameter("username");
  String password = request.getParameter("password");
  if (submit != null && submit.equals("Submit")) {
    if (username == null
            || username.trim().length() == 0
            || password == null
            || password.trim().length() == 0) {
        msg = "All fields are required! Try again! Don't make me go back there!";
    } else if (username.equals("Force an error")) {
        msg = "I'm sorry, but we seem to have encountered an error. Please try again later or contact Hidden Clause customer support.";
    } else {
      Matcher matcher = _pattern.matcher(username);
      if (matcher.find()) {
          msg = "Yo! The username can only contain letters!";
      } else if (username.trim().length() > 30) {
          msg = "Yo! The username can only be 30 letters or less!";
      } else {
          matcher = _pattern.matcher(password);
          if (matcher.find()) {
              msg = "Yo! The password can only contain letters!";
          } else if (password.trim().length() > 30) {
              msg = "Yo! The password can only be 30 letters or less!";
          }
      }
    }

    if (msg == null) {
        request.getRequestDispatcher("confirmation.jsp").forward(request, response);
    } else {
    %>
      <span style="color: red;"><%=msg %></span>
    <%
    }
  }
  %>

confirmation.jsp

<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <title>Confirm Registration Information</title>
  </head>
  <body>
    <%
    String username = request.getParameter("username");
    String password = request.getParameter("password");
    %>
    Name: <%= username %>
    Password: <%= password %>
    <form action="success.jsp">
      <input type="hidden" name="username" value="<%=username %>">
      <input type="hidden" name="password" value="<%=password %>">
      <input type="submit" value="Submit">
    </form>

    <form action="login.jsp?">
      <input type="hidden" name="username" value="<%=username %>">
      <input type="hidden" name="password" value="<%=password %>">
      <input type="submit" name="edit" value="Edit">
    </form>
  </body>
</html>

success.jsp

<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<%
String username = request.getParameter("username");
if (username.equals("Confirmation error")) {
    request.getRequestDispatcher("login.jsp?username=Force%20an%20error&submit=Submit").forward(request, response);
}
%>
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <title>Success!</title>
  </head>
  <body>

  </body>
</html>

Notice how things that we tested previously don’t get tested again (empty input fields, invalid input). Just because an error can happen across scenarios doesn’t mean we have to test it in each scenario.

jWebUnit has a great API. It mixes a little testing (assertTitleEquals()) with a little flow (clickLink()).

What Just Happened?

I know, I know! The actual HTML pages are ugly, ugly, ugly! You can’t even see the two input fields side by side to see if they are really there; but they are really there because the tests passed.

That’s not a problem! The only thing we care about is the flow. We were able to prove that we can go from page to page based on the decisions made by the user or caused by the system. Let some high-priced GUI person step in and design some kick-ass screen that will make Minority Report envious.

Otherwise, wasn’t that interesting? That is quite a bit of code to test our flow and doesn’t really take into account all sorts of other scenarios. But that’s okay! Really. Remember that the pages above are fake…meaning that you would not normally have hard-coded Java in a JSP anyway. I used it just to get my tests up and running; normally you would have JSP tags and/or calls to servlet/Struts/WebWork/Tapestry code that would do all the real work (like validating the input).

What web testing framework are you using? Care to share?

The full code for this is below because I am sure that I left something out somewhere. Remember: not only did I develop this iteratively, I wrote it that way too. Not always a recipe for success.

Thanks to Ken Kranz for suggesting this topic.

Questions

I’m confused! If we are not supposed to test functionality that has been tested previously then why did we test input handling (input length and letters-only)?

Great question! By rights if you are using a web framework like Struts or WebWorks you should already have tests in place that would check for things like missing or incorrect values. In a truly secure application you would have JavaScript doing validation on the client-side and then do the exact same checks again on the server-side just to be sure that someone isn’t trying to get around your security. If you use a web framework and you don’t want to have duplicate validation then, no, you would not have tested input validation within jWebUnit.

From where did Molotov cocktails get their name?

They are called Molotov cocktails after Vyacheslav Molotov, the Commissar for Foreign Affairs of the Soviet Union, during World War II. Wikipedia has this to say about Molotov cocktails:

During the [World War II] Winter War, the Soviet air force made extensive use of incendiaries and cluster bombs against Finnish troops and fortifications. When Soviet People’s Commissar for Foreign Affairs Vyacheslav Molotov claimed in radio broadcasts that the Soviet Union was not dropping bombs but rather delivering food to the starving Finns, the Finns started to call the air bombs Molotov bread baskets. Soon they responded by attacking advancing tanks with “Molotov cocktails” which were “a drink to go with the food”.

Can’t make this stuff up.

[What? You were expecting questions related to jWebUnit? Oh, c’mon!]

References

Test Web applications with HttpUnit is a great article on unit testing your web application and, even though it was written in 2004 (there were people back then?) does a great job of discussing the sorts of architectural and philosophical things you should consider as you add (more) testing to your process.

Tools For Unit Testing Java Web Applications

Unit Testing Web Applications

JUnitDoclet

Continuous Integration
Team City

Code

UseCaseRegistrationTest.java

/**
 * Coder beware: this code is not warranted to do anything.
 *
 * Copyright Dec 12, 2009 Carlos Valcarcel
 */
package com.hiddenclause.jwebunit.example;

import net.sourceforge.jwebunit.junit.WebTester;
import net.sourceforge.jwebunit.util.TestingEngineRegistry;

import org.junit.Before;
import org.junit.Test;


/**
 * @author carlos
 *
 */
public class UseCaseRegistrationTest {
    private WebTester _webTester;

    @Before
    public void setUp() {
        _webTester = new WebTester();
        _webTester.setTestingEngineKey(TestingEngineRegistry.TESTING_ENGINE_HTMLUNIT);
        
        _webTester.setBaseUrl("http://localhost:8080/hiddenclause"); //$NON-NLS-1$
    }

    @Test
    public void testScenario1EverythingWorks() {
        _webTester.beginAt("/login.jsp");
        
        _webTester.assertTitleEquals("Welcome! Register here!");

        String username = "Paul Revere";
        String password = "OneIfByLand";
        _webTester.setTextField("username", username);
        _webTester.setTextField("password", password);
        
        _webTester.submit();
        
        _webTester.assertTitleEquals("Confirm Registration Information");
        
        _webTester.assertTextPresent(username);
        _webTester.assertTextPresent(password);
        
        _webTester.submit();
        
        _webTester.assertTitleEquals("Success!");
        _webTester.assertTextPresent("Thanks for registering!");
    }
    
    @Test
    public void testScenario1EmptyInputFields() {
        _webTester.beginAt("/login.jsp");
        
        _webTester.submit();
        
        assertLoginPageWithErrorMessage("All fields are required! Try again! Don't make me go back there!");
    }
    
    /*
     * The code for these methods is the same. Refactored them.
     */
    @Test
    public void testScenario1InvalidNameInputNumeric() {
        assertFieldNumericErrorFound("thx1138", "hi", "Yo! The username can only contain letters!");
    }
    
    @Test
    public void testScenario1InvalidPasswordInputNumeric() {
        assertFieldNumericErrorFound("Paul Revere", "thx1138", "Yo! The password can only contain letters!");
    }
    
    private void assertFieldNumericErrorFound(String username, String password,
                                    String errMsg) {
        _webTester.beginAt("/login.jsp");
        
        _webTester.setTextField("username", username);
        _webTester.setTextField("password", password);
        _webTester.submit();
        
        assertLoginPageWithErrorMessage(errMsg);
    }

    /*
     * The code for these methods is the same. Refactored them.
     */
    @Test
    public void testScenario1InvalidNameInputLength() {
        assertFieldLengthErrorFound("abcdefghijklmnopqrstuvwxyzABCDE", "hi", "Yo! The username can only be 30 letters or less!");
    }
    
    @Test
    public void testScenario1InvalidPasswordInputLength() {
        assertFieldLengthErrorFound("Paul Revere", "abcdefghijklmnopqrstuvwxyzABCDE", "Yo! The password can only be 30 letters or less!");
    }
    
    private void assertFieldLengthErrorFound(String username, String password,
                                             String errMsg) {
        _webTester.beginAt("/login.jsp");
        
        _webTester.setTextField("username", username);
        _webTester.setTextField("password", password);
        _webTester.submit();
        
        assertLoginPageWithErrorMessage(errMsg);
    }
    
    @Test
    public void testScenario1UnknownErrorOnSubmit() {
        _webTester.beginAt("/login.jsp");
        
        String username = "Force an error";
        String password = "VeryBad";
        _webTester.setTextField("username", username);
        _webTester.setTextField("password", password);

        _webTester.submit();
        
        assertLoginPageWithErrorMessage("I'm sorry, but we seem to have encountered an error. Please try again later or contact Hidden Clause customer support.");
    }
    
    @Test
    public void testScenario2UserReEntersRegistrationInformation() {
        _webTester.beginAt("/login.jsp");
        
        String username = "Paul Revere";
        String password = "OneIfByLand";
        _webTester.setTextField("username", username);
        _webTester.setTextField("password", password);
        _webTester.submit();
        
        _webTester.assertTitleEquals("Confirm Registration Information");
        _webTester.submit("edit");
        
        _webTester.assertTitleEquals("Welcome! Register here!");
        _webTester.assertTextFieldEquals("username", username);
        _webTester.assertTextFieldEquals("password", password);
    }

    @Test
    public void testScenario3SystemErrorOnConfirm() {
        _webTester.beginAt("/login.jsp");
        
        String username = "Confirmation error";
        String password = "OneIfByLand";
        _webTester.setTextField("username", username);
        _webTester.setTextField("password", password);
        _webTester.submit();
        
        _webTester.assertTitleEquals("Confirm Registration Information");
        _webTester.submit();

        assertLoginPageWithErrorMessage("I'm sorry, but we seem to have encountered an error. Please try again later or contact Hidden Clause customer support.");
    }
    
    private void assertLoginPageWithErrorMessage(String errorMessage) {
        _webTester.assertTitleEquals("Welcome! Register here!");
        _webTester.assertTextPresent(errorMessage);
    }

}

login.jsp

<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<%@page import="java.util.regex.Pattern"%>
<%@page import="java.util.regex.Matcher"%>
<%!
 private Pattern _pattern = Pattern.compile("[0-9]");
%>
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <title>Welcome! Register here!</title>
  </head>
  <body>
  <%
  String msg = null;
  String submit = request.getParameter("submit");
  String username = request.getParameter("username");
  String password = request.getParameter("password"); 
  if (submit != null && submit.equals("Submit")) {
    if (username == null
            || username.trim().length() == 0
            || password == null
            || password.trim().length() == 0) {
        msg = "All fields are required! Try again! Don't make me go back there!";
    } else if (username.equals("Force an error")) {
        msg = "I'm sorry, but we seem to have encountered an error. Please try again later or contact Hidden Clause customer support.";
    } else {
      Matcher matcher = _pattern.matcher(username);
      if (matcher.find()) {
          msg = "Yo! The username can only contain letters!";
      } else if (username.trim().length() > 30) {
          msg = "Yo! The username can only be 30 letters or less!";
      } else {
          matcher = _pattern.matcher(password);
          if (matcher.find()) {
              msg = "Yo! The password can only contain letters!";
          } else if (password.trim().length() > 30) {
              msg = "Yo! The password can only be 30 letters or less!";
          }
      }
    }
  
    if (msg == null) {
        request.getRequestDispatcher("confirmation.jsp").forward(request, response);
    } else {
    %>
      <span style="color: red;"><%=msg %></span>
    <%
    }
  }
  %>
    <form action="login.jsp">
        <input name="username" value='<%=username != null ? username : "" %>'>
        <input name="password" type="hidden" value='<%= password != null ? password : "" %>'>
        <input type="submit" value="Submit" name="submit">
    </form>
  </body>
</html>

confirmation.jsp

<%@ page language="java" contentType="text/html; charset=UTF-8"
  pageEncoding="UTF-8"%>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">

<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <title>Confirm Registration Information</title>
  </head>
  <body>
    <%
    String username = request.getParameter("username");
    String password = request.getParameter("password"); 
    %>
    Name: <%= username %>
    Password: <%= password %>
    <form action="success.jsp">
      <input type="hidden" name="username" value="<%=username %>">
      <input type="hidden" name="password" value="<%=password %>">
      <input type="submit" value="Submit">
    </form>
    
    <form action="login.jsp?">
      <input type="hidden" name="username" value="<%=username %>">
      <input type="hidden" name="password" value="<%=password %>">
      <input type="submit" name="edit" value="Edit">
    </form>
  </body>
</html>

success.jsp

<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<%
String username = request.getParameter("username");
if (username.equals("Confirmation error")) {
    request.getRequestDispatcher("login.jsp?username=Force%20an%20error&submit=Submit").forward(request, response);
}
%>
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <title>Success!</title>
  </head>
  <body>
    Thanks for registering!
  </body>
</html>

Writing an Eclipse Plug-in (Part 13): Common Navigator: Adding Tests

December 8, 2009 Leave a comment

And now it is time for the mundane.

While I firmly believe in test-driven development I do not believe in test-driven learning; that means that while tests are great to insure that your software works as advertised (or at least as much of it as you could think of), testing is not a good way to learn implementation. I know the physicists out there will disagree with me, but learning the black box behavior of a system is quite different than learning how to build the actual clockwork mechanism that makes something go.

With that said, at some point we do need to refactor the code and we can’t safely refactor the code without some tests to prove that our refactoring hasn’t broken anything.

We have been coding without a net in the interest of keeping the learning as noise-free as possible. Now we return to the part of the coding that we would normally do as we developed the code.

In other words, time for code hygiene.

What to do

  1. Create a plug-in test project for the navigator
    1. Enter the following:
      • Project name: customnavigator.test
      • Eclipse version: 3.5
    2. Click Next
    3. Enter the following:
      • Version: 1.0.1.3 [Actually anything you want]
      • Name: Custom Navigator Test
    4. Click Finish
  2. Clean up MANIFEST.MF
    1. Click the MANIFEST.MF tab
    2. Move the cursor to line 1 and Press Ctrl+1
    3. Select Add Missing Packages
    4. Move the cursor to line 3 and Press Ctrl+1
    5. Select Externalize the Bundle-Name header
    6. Save the file
  3. Dependencies tab: Add org.junit4
  4. Dependencies tab: Add org.eclipse.core.resources
  5. Copy easymock.jar to your project. Add a lib folder under your test project folder, copy the easymock.jar file and add it to Runtime –> Classpath
  6. Open the customnavigator.test Properties dialog. In the Project References element put a check mark next to the customnavigator project. Click Finish.
  7. Implement customnavigator.navigator.ContentProviderTest in the customnavigator.test project
    1. Create a new JUnit class named customnavigator.navigator.ContentProviderTest
    2. Test getParent()
    3. Test getChildren()
    4. Test hasChildren()

One of the tests, getChildren(), pointed out a bug: when a project came in, custom or not, it was being wrapped and saved in the _wrapperCache. The only projects that should be in the wrapper cache are projects of type CustomProjectParents. While not fatal, it was still wrong. Not a bad catch.

Here is the corrected code.

ContentProvider.java

    private Object[] createCustomProjectParents(IProject[] projects) {
        Object[] result = null;
        
        List<Object> list = new ArrayList<Object>();
        for (int i = 0; i < projects.length; i++) {
            Object customProjectParent = _wrapperCache.get(projects[i].getName()); 
            if (customProjectParent == null) {
                customProjectParent = createCustomProjectParent(projects[i]);
                if (customProjectParent != null) {
                    _wrapperCache.put(projects[i].getName(), customProjectParent);
                }
            }

            if (customProjectParent != null) {
                list.add(customProjectParent);
            } // else ignore the project
        }
        
        result = new Object[list.size()];
        list.toArray(result);
        
        return result;
    }

That brings the number of test projects up to 2.

Why did we do that?

Just as a review about TDD from my rather narrow/myopic perspective (and not necessarily in this order):

  • Don’t test the platform
  • Don’t test trivial logic (i.e. trivial getters and setters)
  • Test boundary conditions that will cause errors
  • Test success conditions

So, what kinds of tests do we need? Well, the easiest way is to pretend we know how to implement the behavior, but haven’t actually written it yet. That should give us a clarity of purpose known only to those who already know the answer.

What does Eclipse expect the content provider to provide? Well, content. In our case, the content is the custom project in its variations; no other project/content types need apply.

As ContentProvider is just another POJO we can test it in a pretty standalone way. Also, even though our content provider implements an interface that extends an interface that extends an interface, we really only care about the methods we overrode. Of course, when I made the following list to see which methods I care about it turns out I had to override them all:

public class ContentProvider implements ITreeContentProvider, IResourceChangeListener {
    // From ITreeContentProvider
    @Override
    public Object[] getChildren(Object parentElement) {
      ...
    }

    @Override
    public Object getParent(Object element) {
      ...
    }

    @Override
    public boolean hasChildren(Object element) {
      ...
    }

    // From IStructuredContentProvider
    @Override
    public Object[] getElements(Object inputElement) {
      ...
    }

    // From IContentProvider
    @Override
    public void dispose() {
      ...
    }

    @Override
    public void inputChanged(Viewer viewer, Object oldInput, Object newInput) {
      ...
    }

    // From IResourceChangeListener
    @Override
    public void resourceChanged(IResourceChangeEvent event) {
      ...
    }

}

The EasyMock framework will also make these tests a simpler to implement. I am not going to try to convince you one way or another to use EasyMock or any other mock object framework. Every time I use EasyMock my life is easier. If there is a simpler mock object framework let me know, otherwise pick one and get to work.

For example, when I thought about the tests for ContentProvider I wasn’t sure which I should write first so I took the path of least resistence:

  • getParent()
    • Input: IWorkspaceRoot, Output: null
    • Input: IProject, Output: non-null
    • Input: ICustomProjectElement, Output: non-null (could be an IWorkspaceRoot, or one of the CustomProject wrappers)
    • Input: anything else (including null), Output: zero length array
  • getChildren()
    • Input: IWorkspaceRoot, Output: null if no projects exist or if the projects are not of of the Custom Project nature.
    • Input: IWorkspaceRoot, Output: non-null if a Custom Project exists
    • Input: IWorkspaceRoot w/ 3 projects (1 non-custom, 1 custom, 1 non-custom), Output: an array with one custom project
    • Input: IWorkspaceRoot w/ 3 projects (1 custom, 1 non-custom, 1 custom), Output: an array with two custom projects
    • Input: IProject, Output: null (by defintion, if it were a CustomProject it would be wrapped already)
    • Input: ICustomProjectElement, Output: non-null unless if is a leaf child like CustomProjectSchemaFilters
    • Input: anything else (including null), Output: zero length array
  • hasChildren()
    • Input: IWorkspaceRoot, Output: false if the projects no proejcts exist or are not Custom Projects otherwise true
    • Input: ICustomProjectElement, Output: false if it is a leaf child like CustomProjectSchemaFilters, true otherwise
    • Input: anything else (including null), Output: false

Seems like a lot to think about doesn’t it? That is the whole idea. [Programming is no more about typing than writing is; in fact, programming is just as much about thinking as writing is.] Under what conditions can something fail? When it “succeeds” did it succeed properly? Some of the above I normally consider as I write the tests and others happen as I learn about the behavior as I implement. White boards are my friend.

Also, tests, like the ones for ICustomProjectElement, normally help you discover that you need data types like ICustomProjectElement. In this case, we skipped a few steps.

It’s okay; I forgive us.

Finally, I am not testing:

  • getElement(): since this calls getChildren() there is no reason to test this.
  • dispose(): I have no idea how I would do that. Sadly, I do have to make sure that I release any resources for which I am responsible, but I am not sure how I would do that except to simply remember that I need to do that in dispose() (can you say time bomb?). Also, it is trivial enough so I can safely ignore it for now.
  • inputChanged(): having implemented it I can safely say that testing an assignment at this point is…pointless.
  • resourceChanged(): This is purely GUI behavior. I suppose I could test it if the logic were complex, but for now it is not.

Being less than a TDD purist is hard to admit, but what the heck, I am not as much of a TDD purist as I would like folks to believe. Sometimes, I can’t come up with that perfect scenario that will light the way for me to create a host of absolutely incredible tests that will leave my code both bug-free and completely covered.

In any case, I am not going to go over every test or how I agonized over them or how much I drank to get through them. Red Bull is overrated.

In addition, clean up customnavigator.test.Activator:
– comment the empty constructor
– add @Override to start()
– add @Override to stop()

More True Confessions

And this is where we write all kinds of test code for the CustomNavigator; only CustomProjects should appear and their various nodes should stay open if they were open when we changed something or should stay closed when we changed something.

We could test things like:

  • a generic project – assert an empty custom navigator in a fresh workspace
  • a custom project – assert one project in the custom navigator
  • a generic project and a custom project – assert one project in the custom navigator

There is only one problem (or perhaps we should consider it an opportunity): that is testing the platform. Making sure that ContentProvider is called with an IWorkspaceRoot was a plugin.xml configuration, not code, so what are we testing anyway? Actually, we would be testing the ContentProvider! Again!

I know we had fun doing it the first time, but I’ll pass on doing it more than once.

I am also not going to write any tests for CustomProjectParent or any of the children that come from it. Why? They are simple. No point wasting time on them until the logic contained by them is complex enough to warrant it.

Kinda makes you wish we had refactored them earlier. No worries; we do that in the next post.

What Just Happened?

Some of you may look at the tests and wonder how does using EasyMock make the job any easier? It is not about EasyMock; it is about testing the expected behavior from the code regardless of what the actual input is.

For example, in testGetChildrenForICustomProjectElementWithNoChildren() and testGetChildrenForICustomProjectElementWithChildren() I tested for an ICustomProjectElement with children and with no children, but I did it without using the CustomProjectParent type or any of its children. The reason for that is both simple and important: I am not testing CustomProjectParent or its children; I am testing ContentProvider. By mixing the testing of ContentProvider and CustomProjectParent (or any of the children) I run the risk of testing something I don’t need to test, or worse, forgetting to test something I should have tested.

Next time: Now that the tests are mostly out of the way it is time to refactor the children.

The cat is tired.

Code

ContentProviderTest.java

/**
 * Coder beware: this code is not warranted to do anything.
 *
 * Copyright Dec 6, 2009 Carlos Valcarcel
 */
package customnavigator.navigator;

import org.easymock.EasyMock;
import org.eclipse.core.resources.IProject;
import org.eclipse.core.resources.IProjectNature;
import org.eclipse.core.resources.IWorkspace;
import org.eclipse.core.resources.IWorkspaceRoot;
import org.eclipse.core.runtime.CoreException;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;


/**
 * @author carlos
 *
 */
public class ContentProviderTest {
    private static final String CUSTOMPLUGIN_PROJECT_NATURE = "customplugin.projectNature"; //$NON-NLS-1$
    
    private ContentProvider _contentProvider;

    @Test
    public void testGetParentForIWorkspaceRoot() {
        Object actual = null;
        
        IWorkspaceRoot workspaceRoot = EasyMock.createStrictMock(IWorkspaceRoot.class);
        actual = _contentProvider.getParent(workspaceRoot);
        
        Assert.assertNull(actual);
    }
    
    @Test
    public void testGetParentForNull() {
        Object actual = null;
        
        actual = _contentProvider.getParent(null);
        
        Assert.assertNull(actual);
    }
    
    @Test
    public void testGetParentForObject() {
        Object actual = null;
        
        actual = _contentProvider.getParent(new Object());
        
        Assert.assertNull(actual);
    }
    
    @Test
    public void testGetParentForIProject() {
        IWorkspaceRoot workspaceRoot = EasyMock.createStrictMock(IWorkspaceRoot.class);
        IWorkspace workspace = EasyMock.createStrictMock(IWorkspace.class);
        IProject project = EasyMock.createStrictMock(IProject.class);
        project.getWorkspace();
        EasyMock.expectLastCall().andReturn(workspace);
        workspace.getRoot();
        EasyMock.expectLastCall().andReturn(workspaceRoot);
        
        EasyMock.replay(workspaceRoot, workspace, project);
        
        Object actual = _contentProvider.getParent(project);
        Assert.assertNotNull(actual);
        
        EasyMock.verify(workspaceRoot, workspace, project);
    }
    
    @Test
    public void testGetParentForICustomProjectElement() {
        Object parent = EasyMock.createNiceControl();
        ICustomProjectElement customProjectElement = EasyMock.createStrictMock(ICustomProjectElement.class);
        customProjectElement.getParent();
        EasyMock.expectLastCall().andReturn(parent);
        
        EasyMock.replay(customProjectElement);
        
        Object actual = _contentProvider.getParent(customProjectElement);
        Assert.assertNotNull(actual);
        
        EasyMock.verify(customProjectElement);
    }

    @Test
    public void testGetChildrenForIWorkspaceRootWithNoProjects() {
        IProject [] projects = {};
        IWorkspaceRoot workspaceRoot = EasyMock.createStrictMock(IWorkspaceRoot.class);
        workspaceRoot.getProjects();
        EasyMock.expectLastCall().andReturn(projects);
        
        EasyMock.replay(workspaceRoot);
        
        Object [] actual = _contentProvider.getChildren(workspaceRoot);
        Assert.assertNotNull(actual);
        Assert.assertTrue(actual.length == 0);
        
        EasyMock.verify(workspaceRoot);
    }
    
    @Test
    public void testGetChildrenForIWorkspaceRootWithNoCustomProjects() throws CoreException {
        IProject [] projects = new IProject[1];
        IProject project = EasyMock.createStrictMock(IProject.class);
        
        projects[0] = project;
        
        IWorkspaceRoot workspaceRoot = EasyMock.createStrictMock(IWorkspaceRoot.class);
        workspaceRoot.getProjects();
        EasyMock.expectLastCall().andReturn(projects);
        
        project.getName();
        EasyMock.expectLastCall().andReturn("non-custom project"); //$NON-NLS-1$
        
        project.getNature(CUSTOMPLUGIN_PROJECT_NATURE);
        EasyMock.expectLastCall().andReturn(null);
        
        EasyMock.replay(workspaceRoot, project);
        
        Object [] actual = _contentProvider.getChildren(workspaceRoot);
        Assert.assertNotNull(actual);
        Assert.assertTrue(actual.length == 0);
        
        EasyMock.verify(workspaceRoot, project);
    }
    
    @Test
    public void testGetChildrenForIWorkspaceRootWithOneCustomProject() throws CoreException {
        IProject [] projects = new IProject[1];
        IProject project = EasyMock.createStrictMock(IProject.class);
        
        projects[0] = project;
        
        IWorkspaceRoot workspaceRoot = EasyMock.createStrictMock(IWorkspaceRoot.class);
        workspaceRoot.getProjects();
        EasyMock.expectLastCall().andReturn(projects);
        
        String projectName = "custom project"; //$NON-NLS-1$
        project.getName();
        EasyMock.expectLastCall().andReturn(projectName);
        
        project.getNature(CUSTOMPLUGIN_PROJECT_NATURE);
        EasyMock.expectLastCall().andReturn(EasyMock.createMock(IProjectNature.class));

        project.getName();
        EasyMock.expectLastCall().andReturn(projectName);
        
        EasyMock.replay(workspaceRoot, project);
        
        Object [] actual = _contentProvider.getChildren(workspaceRoot);
        Assert.assertNotNull(actual);
        Assert.assertTrue(actual.length == 1);
        Assert.assertEquals(project, ((CustomProjectParent)actual[0]).getProject());
        
        EasyMock.verify(workspaceRoot, project);
    }
    
    @Test
    public void testGetChildrenForIWorkspaceRootWithOneCustomProjectTwoNonCustomProjects() throws CoreException {
        IProject nonCustomProject1 = EasyMock.createStrictMock(IProject.class);
        IProject nonCustomProject2 = EasyMock.createStrictMock(IProject.class);
        IProject customProject = EasyMock.createStrictMock(IProject.class);
        
        IProject[] projects = {
                nonCustomProject1,
                customProject,
                nonCustomProject2
        };
        
        IWorkspaceRoot workspaceRoot = EasyMock.createStrictMock(IWorkspaceRoot.class);
        workspaceRoot.getProjects();
        EasyMock.expectLastCall().andReturn(projects);
        
        String bogusProjectName = "bogus project"; //$NON-NLS-1$
        String customProjectName = "custom project"; //$NON-NLS-1$
        nonCustomProject1.getName();
        EasyMock.expectLastCall().andReturn(bogusProjectName);
        
        nonCustomProject1.getNature(CUSTOMPLUGIN_PROJECT_NATURE);
        EasyMock.expectLastCall().andReturn(null);

        customProject.getName();
        EasyMock.expectLastCall().andReturn(customProjectName);
        
        customProject.getNature(CUSTOMPLUGIN_PROJECT_NATURE);
        EasyMock.expectLastCall().andReturn(EasyMock.createMock(IProjectNature.class));

        customProject.getName();
        EasyMock.expectLastCall().andReturn(customProjectName);
        
        nonCustomProject2.getName();
        EasyMock.expectLastCall().andReturn(bogusProjectName);
        
        nonCustomProject2.getNature(CUSTOMPLUGIN_PROJECT_NATURE);
        EasyMock.expectLastCall().andReturn(null);

        EasyMock.replay(workspaceRoot, nonCustomProject1, customProject, nonCustomProject2);
        
        Object [] actual = _contentProvider.getChildren(workspaceRoot);
        Assert.assertNotNull(actual);
        Assert.assertTrue(actual.length == 1);
        Assert.assertEquals(customProject, ((CustomProjectParent)actual[0]).getProject());
        
        EasyMock.verify(workspaceRoot, nonCustomProject1, nonCustomProject2, customProject);
    }

    @Test
    public void testGetChildrenForIWorkspaceRootWithOneNonCustomProjectTwoCustomProjects() throws CoreException {
        IProject customProject1 = EasyMock.createStrictMock(IProject.class);
        IProject customProject2 = EasyMock.createStrictMock(IProject.class);
        IProject nonCustomProject = EasyMock.createStrictMock(IProject.class);
        
        IProject[] projects = {
                customProject1,
                nonCustomProject,
                customProject2
        };
        
        IWorkspaceRoot workspaceRoot = EasyMock.createStrictMock(IWorkspaceRoot.class);
        workspaceRoot.getProjects();
        EasyMock.expectLastCall().andReturn(projects);
        
        String bogusProjectName = "bogus project"; //$NON-NLS-1$
        String customProjectName1 = "custom project 1"; //$NON-NLS-1$
        String customProjectName2 = "custom project 2"; //$NON-NLS-1$
        customProject1.getName();
        EasyMock.expectLastCall().andReturn(customProjectName1);
        
        customProject1.getNature(CUSTOMPLUGIN_PROJECT_NATURE);
        EasyMock.expectLastCall().andReturn(EasyMock.createMock(IProjectNature.class));

        customProject1.getName();
        EasyMock.expectLastCall().andReturn(customProjectName1);
        
        nonCustomProject.getName();
        EasyMock.expectLastCall().andReturn(bogusProjectName);
        
        nonCustomProject.getNature(CUSTOMPLUGIN_PROJECT_NATURE);
        EasyMock.expectLastCall().andReturn(null);

        customProject2.getName();
        EasyMock.expectLastCall().andReturn(customProjectName2);
        
        customProject2.getNature(CUSTOMPLUGIN_PROJECT_NATURE);
        EasyMock.expectLastCall().andReturn(EasyMock.createMock(IProjectNature.class));

        customProject2.getName();
        EasyMock.expectLastCall().andReturn(customProjectName2);
        
        EasyMock.replay(workspaceRoot, customProject1, nonCustomProject, customProject2);
        
        Object [] actual = _contentProvider.getChildren(workspaceRoot);
        Assert.assertNotNull(actual);
        Assert.assertTrue(actual.length == 2);
        Assert.assertEquals(customProject1, ((CustomProjectParent)actual[0]).getProject());
        Assert.assertEquals(customProject2, ((CustomProjectParent)actual[1]).getProject());
        
        EasyMock.verify(workspaceRoot, customProject1, nonCustomProject, customProject2);
    }

    /**
     * If a resource of type IProject comes in ignore it. If it were
     * a Custom Project it would be wrapped already.
     */
    @Test
    public void testGetChildrenForIProjectNotCustomProject() {
        IProject project = EasyMock.createStrictMock(IProject.class);
        
        Object [] actual = _contentProvider.getChildren(project);
        Assert.assertNotNull(actual);
        Assert.assertTrue(actual.length == 0);
    }
    
    @Test
    public void testGetChildrenForICustomProjectElementWithNoChildren() {
        assertChildrenFromICustomProjectElement(0);
    }

    /**
     * Check that an ICustomProjectElement returns some kids. Send back 5 to prove
     * the right method is called. 
     */
    @Test
    public void testGetChildrenForICustomProjectElementWithChildren() {
        assertChildrenFromICustomProjectElement(5);
    }
    
    @Before
    public void setUp() {
        _contentProvider = new ContentProvider();
    }

    private void assertChildrenFromICustomProjectElement(int childCount) {
        Object [] children = new Object[childCount];
        ICustomProjectElement customProjectElement = EasyMock.createStrictMock(ICustomProjectElement.class);
        
        customProjectElement.getChildren();
        EasyMock.expectLastCall().andReturn(children);

        EasyMock.replay(customProjectElement);
        
        Object [] actual = _contentProvider.getChildren(customProjectElement);
        Assert.assertNotNull(actual);
        Assert.assertTrue(actual.length == childCount);

        EasyMock.verify(customProjectElement);
    }

}

ContentProvider.java

/**
 * Coder beware: this code is not warranted to do anything.
 * Copyright Oct 17, 2009 Carlos Valcarcel
 */
package customnavigator.navigator;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.eclipse.core.resources.IProject;
import org.eclipse.core.resources.IResourceChangeEvent;
import org.eclipse.core.resources.IResourceChangeListener;
import org.eclipse.core.resources.IWorkspaceRoot;
import org.eclipse.core.resources.ResourcesPlugin;
import org.eclipse.core.runtime.CoreException;
import org.eclipse.jface.viewers.ITreeContentProvider;
import org.eclipse.jface.viewers.TreePath;
import org.eclipse.jface.viewers.TreeViewer;
import org.eclipse.jface.viewers.Viewer;

import customplugin.natures.ProjectNature;

/**
 * @author carlos
 */
public class ContentProvider implements ITreeContentProvider, IResourceChangeListener {

    private static final Object[]   NO_CHILDREN = {};
    private Map<String, Object> _wrapperCache = new HashMap<String, Object>();
    private Viewer _viewer;

    public ContentProvider() {
        ResourcesPlugin.getWorkspace().addResourceChangeListener(this, IResourceChangeEvent.POST_CHANGE);
    }

    /*
     * (non-Javadoc)
     * @see
     * org.eclipse.jface.viewers.ITreeContentProvider#getChildren(java.lang.Object)
     */
    @Override
    public Object[] getChildren(Object parentElement) {
        Object[] children = null;
        if (IWorkspaceRoot.class.isInstance(parentElement)) {
            IProject[] projects = ((IWorkspaceRoot)parentElement).getProjects(); 
            children = createCustomProjectParents(projects);
        } else if (ICustomProjectElement.class.isInstance(parentElement)) {
            children = ((ICustomProjectElement) parentElement).getChildren();
        } else {
            children = NO_CHILDREN;
        }

        return children;
    }

    /*
     * (non-Javadoc)
     * @see
     * org.eclipse.jface.viewers.ITreeContentProvider#getParent(java.lang.Object)
     */
    @Override
    public Object getParent(Object element) {
        Object parent = null;
            
        if (IProject.class.isInstance(element)) {
            parent = ((IProject)element).getWorkspace().getRoot();
        } else if (ICustomProjectElement.class.isInstance(element)) {
            parent = ((ICustomProjectElement)element).getParent();
        } // else parent = null if IWorkspaceRoot or anything else
        
        return parent;
    }

    /*
     * (non-Javadoc)
     * @see
     * org.eclipse.jface.viewers.ITreeContentProvider#hasChildren(java.lang.Object)
     */
    @Override
    public boolean hasChildren(Object element) {
        boolean hasChildren = false;

        if (IWorkspaceRoot.class.isInstance(element)) {
            hasChildren = ((IWorkspaceRoot)element).getProjects().length > 0;
        } else if (ICustomProjectElement.class.isInstance(element)) {
            hasChildren = ((ICustomProjectElement)element).hasChildren();
        }
        // else it is not one of these so return false
        
        return hasChildren;
    }

    /*
     * (non-Javadoc)
     * @see
     * org.eclipse.jface.viewers.IStructuredContentProvider#getElements(java.lang.Object)
     */
    @Override
    public Object[] getElements(Object inputElement) {
        // This is the same as getChildren() so we will call that instead
        return getChildren(inputElement);
    }

    /*
     * (non-Javadoc)
     * @see org.eclipse.jface.viewers.IContentProvider#dispose()
     */
    @Override
    public void dispose() {
        ResourcesPlugin.getWorkspace().removeResourceChangeListener(this);
    }

    /*
     * (non-Javadoc)
     * @see
     * org.eclipse.jface.viewers.IContentProvider#inputChanged(org.eclipse.jface.viewers.Viewer, java.lang.Object, java.lang.Object)
     */
    @Override
    public void inputChanged(Viewer viewer, Object oldInput, Object newInput) {
        _viewer = viewer;
    }

    @Override
    public void resourceChanged(IResourceChangeEvent event) {
        TreeViewer viewer = (TreeViewer) _viewer;
        TreePath[] treePaths = viewer.getExpandedTreePaths();
        viewer.refresh();
        viewer.setExpandedTreePaths(treePaths); 
    }

    private Object createCustomProjectParent(IProject parentElement) {

        Object result = null;
        try {
            if (parentElement.getNature(ProjectNature.NATURE_ID) != null) {
                result = new CustomProjectParent(parentElement);
            }
        } catch (CoreException e) {
            // Go to the next IProject
        }

        return result;
    }

    private Object[] createCustomProjectParents(IProject[] projects) {
        Object[] result = null;
        
        List<Object> list = new ArrayList<Object>();
        for (int i = 0; i < projects.length; i++) {
            Object customProjectParent = _wrapperCache.get(projects[i].getName()); 
            if (customProjectParent == null) {
                customProjectParent = createCustomProjectParent(projects[i]);
                if (customProjectParent != null) {
                    _wrapperCache.put(projects[i].getName(), customProjectParent);
                }
            }

            if (customProjectParent != null) {
                list.add(customProjectParent);
            } // else ignore the project
        }
        
        result = new Object[list.size()];
        list.toArray(result);
        
        return result;
    }

}